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 ListPrice {
698 source: MarketListSourceKind,
699 item_instance_id: uuid::Uuid,
700 label: String,
701 quantity: Option<u32>,
703 max_qty: u32,
704 input: String,
705 },
706}
707
708#[derive(Debug, Clone)]
710pub struct StoragePickOption {
711 pub item_instance_id: uuid::Uuid,
712 pub label: String,
713 pub quantity: u32,
714 pub category: String,
716}
717
718#[derive(Debug, Clone)]
721pub struct NearbyContainer {
722 pub view: flatland_protocol::PlacedContainerView,
723 pub distance_m: f32,
724 pub rows: Vec<InventoryRow>,
725}
726
727#[derive(Debug, Clone)]
729pub struct KeychainEntry {
730 pub stack: flatland_protocol::ItemStack,
731 pub stowed: bool,
732}
733
734#[derive(Debug, Clone)]
736pub struct MoveOption {
737 pub label: String,
738 pub kind: MoveOptionKind,
739}
740
741#[derive(Debug, Clone, PartialEq)]
742pub enum MoveOptionKind {
743 Move {
744 location: flatland_protocol::InventoryLocation,
745 parent_instance_id: Option<uuid::Uuid>,
746 },
747 PickupPlaced {
749 container_id: String,
750 nest_location: flatland_protocol::InventoryLocation,
751 nest_parent_instance_id: Option<uuid::Uuid>,
752 },
753 RelocatePlaced {
755 container_id: String,
756 },
757 Use,
759 GrantApply,
761 Drop,
762 SellPlotToCrown {
764 plot_id: uuid::Uuid,
765 },
766 Cancel,
767}
768
769#[derive(Debug, Clone, PartialEq)]
771pub enum FarmAccessRow {
772 PublicToggle,
773 PublicDiscount,
774 AllowRemove {
775 character_id: uuid::Uuid,
776 label: String,
777 tax_discount_bps: u32,
778 },
779 NearbyAdd {
780 name: String,
781 },
782}
783
784#[derive(Debug, Clone)]
786pub struct GrantTargetPicker {
787 pub grant_instance_id: uuid::Uuid,
788 pub grant_label: String,
789 pub effect_id: String,
790 pub mode: String,
791 pub options: Vec<GrantTargetOption>,
792 pub filter: String,
793 pub filter_focused: bool,
794}
795
796#[derive(Debug, Clone)]
797pub struct GrantTargetOption {
798 pub label: String,
799 pub target_instance_id: uuid::Uuid,
800}
801
802#[derive(Debug, Clone)]
804pub struct MovePicker {
805 pub item_instance_id: uuid::Uuid,
806 pub from: flatland_protocol::InventoryLocation,
807 pub item_label: String,
808 pub template_id: String,
809 pub stack_quantity: u32,
810 pub quantity: u32,
811 pub options: Vec<MoveOption>,
812 pub filter: String,
813 pub filter_focused: bool,
814}
815
816#[derive(Debug, Clone)]
818pub struct DestroyPicker {
819 pub item_instance_id: uuid::Uuid,
820 pub from: flatland_protocol::InventoryLocation,
821 pub item_label: String,
822 pub stack_quantity: u32,
823 pub quantity: u32,
824}
825
826#[derive(Debug, Clone)]
828pub struct WorkerGiveOption {
829 pub item_instance_id: uuid::Uuid,
830 pub label: String,
831 pub quantity: u32,
832 pub template_id: String,
833}
834
835#[derive(Debug, Clone)]
837pub struct WorkerGivePicker {
838 pub worker_instance_id: String,
839 pub worker_label: String,
840 pub options: Vec<WorkerGiveOption>,
841}
842
843#[derive(Debug, Clone)]
845pub struct WorkerGiveTargetOption {
846 pub instance_id: String,
847 pub label: String,
848 pub distance_m: f32,
849}
850
851#[derive(Debug, Clone)]
853pub struct WorkerGiveTargetPicker {
854 pub item_instance_id: uuid::Uuid,
855 pub item_label: String,
856 pub quantity: Option<u32>,
857 pub options: Vec<WorkerGiveTargetOption>,
858}
859
860#[derive(Debug, Clone)]
862pub struct WorkerTakePicker {
863 pub worker_instance_id: String,
864 pub worker_label: String,
865 pub options: Vec<WorkerGiveOption>,
866 pub quantity: u32,
868}
869
870pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
872
873#[derive(Debug, Clone)]
875pub struct WorkerTeachOption {
876 pub blueprint_id: String,
877 pub label: String,
878 pub cost_copper: u64,
879 pub min_level: u32,
880 pub worker_level: u32,
881 pub can_afford: bool,
882 pub level_ok: bool,
883}
884
885#[derive(Debug, Clone)]
887pub struct WorkerTeachPicker {
888 pub worker_instance_id: String,
889 pub worker_label: String,
890 pub worker_level: u32,
891 pub options: Vec<WorkerTeachOption>,
892}
893
894#[derive(Debug, Clone, Default)]
897pub struct StickyWorkerStep {
898 shown: String,
899 pending: String,
900 pending_since: Option<Instant>,
901}
902
903impl StickyWorkerStep {
904 fn from_label(label: String) -> Self {
905 Self {
906 shown: label.clone(),
907 pending: label,
908 pending_since: Some(Instant::now()),
909 }
910 }
911
912 fn observe(&mut self, label: &str, now: Instant) {
913 let pending_since = self.pending_since.unwrap_or(now);
914 if label == self.pending {
915 if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD
916 {
917 self.shown = self.pending.clone();
918 }
919 return;
920 }
921 self.pending = label.to_string();
922 self.pending_since = Some(now);
923 if self.shown.is_empty() {
925 self.shown = self.pending.clone();
926 }
927 }
928}
929
930#[derive(Debug, Clone, Default)]
933pub struct StickyWorkerError {
934 message: String,
935 last_seen: Option<Instant>,
936}
937
938impl StickyWorkerError {
939 fn observe(&mut self, err: Option<&str>, now: Instant) {
940 if let Some(e) = err {
941 if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
942 self.message = e.to_string();
943 self.last_seen = Some(now);
944 }
945 return;
946 }
947 if let Some(seen) = self.last_seen {
948 if now.duration_since(seen) > WORKER_ERROR_HOLD {
949 self.message.clear();
950 self.last_seen = None;
951 }
952 }
953 }
954
955 pub fn shown(&self, now: Instant) -> Option<&str> {
956 if self.message.is_empty() {
957 return None;
958 }
959 let seen = self.last_seen?;
960 if now.duration_since(seen) > WORKER_ERROR_HOLD {
961 return None;
962 }
963 Some(self.message.as_str())
964 }
965}
966
967pub fn worker_attention_line(state: &GameState) -> Option<String> {
970 use flatland_protocol::WorkerStateView;
971 let now = Instant::now();
972 for w in &state.hired_workers {
973 if matches!(w.state, WorkerStateView::Strike) {
974 return Some(format!(
975 "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
976 w.label
977 ));
978 }
979 if let Some(err) = state
980 .worker_error_display
981 .get(&w.instance_id)
982 .and_then(|s| s.shown(now))
983 {
984 if !worker_error_is_hud_noise(err) {
985 return Some(format!("Worker {}: {err}", w.label));
986 }
987 }
988 if let Some(err) = &w.last_error {
989 if !worker_error_is_transient(err) && !worker_error_is_hud_noise(err) {
990 return Some(format!("Worker {}: {err}", w.label));
991 }
992 }
993 }
994 None
995}
996
997pub fn worker_error_is_transient(err: &str) -> bool {
999 let e = err.to_ascii_lowercase();
1000 e.contains("continuing route")
1001 || e.contains("storage full")
1002 || e.starts_with("nothing to withdraw")
1003}
1004
1005pub fn worker_error_is_hud_noise(err: &str) -> bool {
1008 let e = err.to_ascii_lowercase();
1009 e.contains("returned to lodging after path")
1010 || e.contains("path failure")
1011 || e.contains("no path to")
1012 || e.contains("pathfinding")
1013 || e.contains("repathing")
1015 || e.contains("nudged clear")
1016}
1017
1018#[derive(Debug, Clone)]
1020pub struct PendingWorkerJobAck {
1021 pub seq: u32,
1022 pub worker_instance_id: String,
1023 pub worker_label: String,
1024 pub idle: bool,
1025 pub stop_count: usize,
1026 pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1027 pub prev_mode: flatland_protocol::WorkerModeView,
1028 pub prev_step_label: String,
1029 pub prev_last_error: Option<String>,
1030}
1031
1032fn push_inventory_rows(
1033 rows: &mut Vec<InventoryRow>,
1034 depth: usize,
1035 stack: &flatland_protocol::ItemStack,
1036 from: &flatland_protocol::InventoryLocation,
1037 from_parent_instance_id: Option<uuid::Uuid>,
1038 section: InventorySection,
1039) {
1040 push_inventory_rows_filtered(
1041 rows,
1042 depth,
1043 stack,
1044 from,
1045 from_parent_instance_id,
1046 section,
1047 "",
1048 );
1049}
1050
1051fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1052 if filter.is_empty() {
1053 return true;
1054 }
1055 let f = filter.to_ascii_lowercase();
1056 let name = stack
1057 .display_name
1058 .as_deref()
1059 .unwrap_or("")
1060 .to_ascii_lowercase();
1061 let tid = stack.template_id.to_ascii_lowercase();
1062 name.contains(&f)
1063 || tid.contains(&f)
1064 || stack
1065 .contents
1066 .iter()
1067 .any(|c| stack_matches_filter(c, filter))
1068}
1069
1070fn push_inventory_rows_filtered(
1071 rows: &mut Vec<InventoryRow>,
1072 depth: usize,
1073 stack: &flatland_protocol::ItemStack,
1074 from: &flatland_protocol::InventoryLocation,
1075 from_parent_instance_id: Option<uuid::Uuid>,
1076 section: InventorySection,
1077 filter: &str,
1078) {
1079 if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1080 return;
1081 }
1082 let self_hit = filter.is_empty() || {
1083 let f = filter.to_ascii_lowercase();
1084 let name = stack
1085 .display_name
1086 .as_deref()
1087 .unwrap_or("")
1088 .to_ascii_lowercase();
1089 let tid = stack.template_id.to_ascii_lowercase();
1090 name.contains(&f) || tid.contains(&f)
1091 };
1092 rows.push(InventoryRow {
1093 depth,
1094 stack: stack.clone(),
1095 from: from.clone(),
1096 from_parent_instance_id,
1097 is_equip_shell: false,
1098 is_chest_shell: false,
1099 section,
1100 });
1101 for child in &stack.contents {
1102 if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1103 push_inventory_rows_filtered(
1104 rows,
1105 depth + 1,
1106 child,
1107 from,
1108 stack.item_instance_id,
1109 section,
1110 if self_hit { "" } else { filter },
1111 );
1112 }
1113 }
1114}
1115
1116#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1117pub enum ShopTab {
1118 #[default]
1119 Buy,
1120 Sell,
1121}
1122
1123#[derive(Debug, Clone)]
1124pub struct NpcChatState {
1125 pub npc_id: String,
1126 pub npc_label: String,
1127 pub lines: Vec<String>,
1128 pub input: String,
1129 pub pending: bool,
1130 pub talk_depth: flatland_protocol::NpcTalkDepth,
1131 pub trade_allowed: bool,
1132 pub banner: Option<String>,
1133}
1134
1135impl Default for NpcChatState {
1136 fn default() -> Self {
1137 Self {
1138 npc_id: String::new(),
1139 npc_label: String::new(),
1140 lines: Vec::new(),
1141 input: String::new(),
1142 pending: false,
1143 talk_depth: flatland_protocol::NpcTalkDepth::Full,
1144 trade_allowed: true,
1145 banner: None,
1146 }
1147 }
1148}
1149
1150#[derive(Debug, Clone)]
1151pub struct GameState {
1152 pub session_id: SessionId,
1153 pub entity_id: EntityId,
1154 pub character_id: Option<uuid::Uuid>,
1156 pub tick: Tick,
1157 pub chunk_rev: u64,
1158 pub content_rev: u64,
1159 pub publish_rev: u64,
1160 pub entities: Vec<EntityState>,
1161 pub player: Option<EntityState>,
1162 pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1163 pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1164 pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1165 pub buildings: Vec<BuildingView>,
1166 pub doors: Vec<DoorView>,
1167 pub interior_map: Option<InteriorMapView>,
1168 pub npcs: Vec<NpcView>,
1169 pub blueprints: Vec<BlueprintView>,
1170 pub world_x0: f32,
1172 pub world_y0: f32,
1173 pub world_width_m: f32,
1174 pub world_height_m: f32,
1175 pub terrain_zones: Vec<TerrainZoneView>,
1176 pub z_platforms: Vec<ZPlatformView>,
1177 pub z_transitions: Vec<ZTransitionView>,
1178 #[doc(hidden)]
1181 pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1182 pub world_clock: flatland_protocol::WorldClock,
1183 pub inventory: std::collections::HashMap<String, u32>,
1184 pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1185 pub logs: VecDeque<String>,
1186 pub intents_sent: u64,
1187 pub ticks_received: u64,
1188 pub connected: bool,
1189 pub disconnect_reason: Option<String>,
1190 pub show_stats: bool,
1191 pub hud_log_hidden: bool,
1193 pub show_equip_menu: bool,
1194 pub equip_menu_index: usize,
1195 pub show_craft_menu: bool,
1196 pub craft_menu_index: usize,
1197 pub craft_batch_quantity: u32,
1199 pub show_shop_menu: bool,
1200 pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1201 pub bank_panel: Option<flatland_protocol::BankPanel>,
1202 pub bank_menu_index: usize,
1203 pub bank_ui_mode: BankUiMode,
1204 pub storage_panel: Option<flatland_protocol::StoragePanel>,
1205 pub market_panel: Option<flatland_protocol::MarketPanel>,
1206 pub market_menu_index: usize,
1208 pub market_filter: String,
1210 pub market_filter_focused: bool,
1211 pub market_category_filter: Option<&'static str>,
1213 pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1215 pub market_ui_mode: MarketUiMode,
1216 pub storage_menu_index: usize,
1217 pub storage_ui_mode: StorageUiMode,
1218 pub shop_tab: ShopTab,
1219 pub shop_menu_index: usize,
1220 pub shop_quantity: u32,
1221 pub shop_trade_log: VecDeque<String>,
1223 pub show_npc_verb_menu: bool,
1224 pub npc_verb_target: Option<String>,
1225 pub npc_verb_index: usize,
1226 pub player_verbs: crate::social::PlayerVerbState,
1228 pub social_chat: crate::social::SocialChatState,
1229 pub trade_ui: crate::social::TradeUiState,
1230 pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1231 pub show_npc_chat: bool,
1232 pub npc_chat: Option<NpcChatState>,
1233 pub show_inventory_menu: bool,
1234 pub inventory_menu_index: usize,
1235 pub inventory_tab: InventoryTab,
1236 pub inventory_filter: String,
1237 pub inventory_filter_focused: bool,
1238 pub show_move_picker: bool,
1239 pub move_picker_index: usize,
1240 pub move_picker: Option<MovePicker>,
1241 pub show_grant_picker: bool,
1242 pub grant_picker_index: usize,
1243 pub grant_picker: Option<GrantTargetPicker>,
1244 pub show_destroy_picker: bool,
1245 pub destroy_confirm_pending: bool,
1246 pub destroy_picker: Option<DestroyPicker>,
1247 pub show_rename_prompt: bool,
1249 pub show_worker_rename: bool,
1251 pub rename_buffer: String,
1252 pub combat_target: Option<EntityId>,
1254 pub combat_target_label: Option<String>,
1255 pub ground_target: Option<(f32, f32, f32)>,
1258 pub combat_fx: Vec<flatland_protocol::CombatFx>,
1260 pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1262 pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1264 pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1266 pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1268 pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1270 pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1272 pub claim_mode: Option<ClaimModeState>,
1274 pub relocate_mode: Option<RelocateModeState>,
1276 pub sell_plot_confirm: Option<uuid::Uuid>,
1278 pub sell_plot_armed_at: Option<Instant>,
1280 pub show_plant_menu: bool,
1282 pub plant_menu_index: usize,
1283 pub show_farm_access: bool,
1285 pub farm_access_name_draft: String,
1287 pub farm_access_discount_bps: u32,
1289 pub farm_access_index: usize,
1291 pub plant_quantity: u32,
1292 pub in_combat: bool,
1293 pub auto_attack: bool,
1294 pub combat_has_los: bool,
1295 pub attack_cd_ticks: u64,
1296 pub gcd_ticks: u64,
1297 pub weapon_ability_id: String,
1298 pub mainhand_template_id: Option<String>,
1299 pub mainhand_label: Option<String>,
1300 pub offhand_template_id: Option<String>,
1301 pub offhand_label: Option<String>,
1302 pub mainhand_hand_slots: u8,
1303 pub defense: Option<flatland_protocol::DefenseHud>,
1304 pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1306 pub carry_mass: f32,
1307 pub carry_mass_max: f32,
1308 pub encumbrance: flatland_protocol::EncumbranceState,
1309 pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1311 pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1313 pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1315 pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1317 pub combat_target_detail: Option<CombatTargetHud>,
1318 pub cast_progress: Option<CastProgressHud>,
1319 pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1321 pub ability_cooldowns: Vec<AbilityCooldownHud>,
1322 pub blocking_active: bool,
1323 pub max_target_slots: u8,
1324 pub combat_slots: Vec<CombatSlotHud>,
1325 pub rotation_presets: Vec<RotationPreset>,
1326 pub known_abilities: Vec<String>,
1328 pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1330 pub hotbar: Vec<Option<String>>,
1332 pub max_abilities_per_rotation: u8,
1334 pub show_loadout_menu: bool,
1335 pub show_keychain_menu: bool,
1336 pub keychain_menu_index: usize,
1337 pub show_rotation_editor: bool,
1338 pub loadout_menu_index: usize,
1340 pub loadout_hotbar_slot: u8,
1342 pub loadout_ability_index: usize,
1344 pub loadout_focus_presets: bool,
1346 pub rotation_editor: RotationEditorState,
1347 pub harvest_in_progress: bool,
1349 pub harvest_started_at: Option<Instant>,
1351 pub pending_craft_ack: Option<(u32, String, u32)>,
1353 pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1354 pub interactables: Vec<flatland_protocol::InteractableView>,
1355 pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1356 pub career: Option<flatland_protocol::PlayerCareerView>,
1357 pub character_sheet_tab: CharacterSheetTab,
1358 pub ledger_period: LedgerPeriod,
1359 pub show_quest_offer: bool,
1360 pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1361 pub show_quest_menu: bool,
1362 pub quest_menu_index: usize,
1363 pub quest_withdraw_confirm: bool,
1364 pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1365 pub show_workers_menu: bool,
1366 pub workers_menu_index: usize,
1367 pub workers_menu_compact: bool,
1369 pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1372 pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1374 pub show_worker_give_picker: bool,
1376 pub worker_give_picker_index: usize,
1377 pub worker_give_picker: Option<WorkerGivePicker>,
1378 pub show_worker_give_target_picker: bool,
1380 pub worker_give_target_picker_index: usize,
1381 pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1382 pub show_worker_take_picker: bool,
1384 pub worker_take_picker_index: usize,
1385 pub worker_take_picker: Option<WorkerTakePicker>,
1386 pub show_worker_teach_picker: bool,
1388 pub worker_teach_picker_index: usize,
1389 pub worker_teach_picker: Option<WorkerTeachPicker>,
1390 pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1392 pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1394 pub attending_worker_instance_id: Option<String>,
1396 pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1398}
1399
1400impl GameState {
1401 pub fn push_log(&mut self, line: impl Into<String>) {
1402 self.logs.push_back(line.into());
1403 while self.logs.len() > MAX_LOG_LINES {
1404 self.logs.pop_front();
1405 }
1406 }
1407
1408 pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1409 self.shop_trade_log.push_back(line.into());
1410 while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1411 self.shop_trade_log.pop_front();
1412 }
1413 }
1414
1415 pub fn clear_shop_trade_log(&mut self) {
1416 self.shop_trade_log.clear();
1417 }
1418
1419 fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1420 if !self.show_shop_menu {
1421 return;
1422 }
1423 let msg = notice.message.trim();
1424 if msg.is_empty() {
1425 return;
1426 }
1427 if notice.coins_delta != 0
1428 || msg.starts_with("Bought ")
1429 || msg.starts_with("Sold ")
1430 || msg.contains("taught you how to craft")
1431 || msg.starts_with("need ")
1432 {
1433 self.push_shop_trade_log(msg);
1434 }
1435 }
1436
1437 pub fn is_alive(&self) -> bool {
1438 self.player
1439 .as_ref()
1440 .and_then(|p| p.vitals)
1441 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1442 .unwrap_or(true)
1443 }
1444
1445 pub fn npc_verb_options(&self) -> Vec<&'static str> {
1447 let Some(ref id) = self.npc_verb_target else {
1448 return vec![];
1449 };
1450 let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1451 return vec!["Talk"];
1452 };
1453 let role = npc.role.as_str();
1454 if Self::npc_role_is_bank(role) {
1455 return vec!["Bank", "Talk"];
1456 }
1457 if Self::npc_role_is_storage(role) {
1458 return vec!["Storage", "Talk"];
1459 }
1460 if Self::npc_role_is_market(role) {
1461 return vec!["Market", "Talk"];
1462 }
1463 if npc.can_trade || Self::npc_role_can_trade(role) {
1464 vec!["Talk", "Trade"]
1465 } else {
1466 vec!["Talk"]
1467 }
1468 }
1469
1470 fn npc_role_can_trade(role: &str) -> bool {
1471 matches!(role, "broker" | "cook" | "farmer" | "merchant")
1472 }
1473
1474 fn npc_role_is_bank(role: &str) -> bool {
1475 role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1476 }
1477
1478 fn npc_role_is_storage(role: &str) -> bool {
1479 role.eq_ignore_ascii_case("storage_manager")
1480 }
1481
1482 fn npc_role_is_market(role: &str) -> bool {
1483 role.eq_ignore_ascii_case("market_clerk")
1484 }
1485
1486 pub fn bank_menu_options(&self) -> Vec<&'static str> {
1487 vec![
1488 "Deposit…",
1489 "Withdraw…",
1490 "Deposit all",
1491 "Withdraw all",
1492 "Transfer…",
1493 ]
1494 }
1495
1496 pub fn storage_menu_options(&self) -> Vec<String> {
1497 let mut opts = vec!["Store…".into(), "Take…".into()];
1498 if let Some(panel) = &self.storage_panel {
1499 for dest in &panel.ship_destinations {
1500 opts.push(format!(
1501 "Ship → {} ({} cp / {} ticks)",
1502 dest.label, dest.fee_copper, dest.travel_ticks
1503 ));
1504 }
1505 }
1506 opts
1507 }
1508
1509 pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1511 self.person_rows()
1512 .into_iter()
1513 .filter(|r| r.depth == 0)
1514 .filter_map(|r| {
1515 let id = r.stack.item_instance_id?;
1516 Some(StoragePickOption {
1517 item_instance_id: id,
1518 label: storage_stack_label(&r.stack),
1519 quantity: r.stack.quantity,
1520 category: r.stack.category.clone().unwrap_or_default(),
1521 })
1522 })
1523 .collect()
1524 }
1525
1526 pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1528 let Some(panel) = &self.storage_panel else {
1529 return Vec::new();
1530 };
1531 panel
1532 .contents
1533 .iter()
1534 .filter_map(|s| {
1535 let id = s.item_instance_id?;
1536 Some(StoragePickOption {
1537 item_instance_id: id,
1538 label: storage_stack_label(s),
1539 quantity: s.quantity,
1540 category: s.category.clone().unwrap_or_default(),
1541 })
1542 })
1543 .collect()
1544 }
1545
1546 pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
1548 let mut opts = Vec::new();
1549 if !self
1550 .market_list_item_options(&MarketListSourceKind::Person)
1551 .is_empty()
1552 {
1553 opts.push((MarketListSourceKind::Person, "On person".into()));
1554 }
1555 if let Some(panel) = &self.market_panel {
1556 for vault in &panel.list_vaults {
1557 let source = MarketListSourceKind::TownStorage {
1558 building_id: vault.building_id.clone(),
1559 };
1560 if self.market_list_item_options(&source).is_empty() {
1561 continue;
1562 }
1563 let label = if vault.building_label.is_empty() {
1564 format!("Town storage ({})", vault.building_id)
1565 } else {
1566 format!("Town storage — {}", vault.building_label)
1567 };
1568 opts.push((source, label));
1569 }
1570 }
1571 opts
1572 }
1573
1574 pub fn market_list_item_options(
1576 &self,
1577 source: &MarketListSourceKind,
1578 ) -> Vec<StoragePickOption> {
1579 let filter = self.market_filter.as_str();
1580 let cat_filter = self.market_category_filter;
1581 let mut opts: Vec<StoragePickOption> = match source {
1582 MarketListSourceKind::Person => self
1583 .person_rows()
1584 .into_iter()
1585 .filter(|r| r.depth == 0)
1586 .filter(|r| self.stack_is_market_listable(&r.stack))
1587 .filter_map(|r| {
1588 let id = r.stack.item_instance_id?;
1589 Some(StoragePickOption {
1590 item_instance_id: id,
1591 label: storage_stack_label(&r.stack),
1592 quantity: r.stack.quantity,
1593 category: r
1594 .stack
1595 .category
1596 .clone()
1597 .or_else(|| {
1598 self.inventory_item_category(&r.stack.template_id)
1599 .map(str::to_string)
1600 })
1601 .unwrap_or_default(),
1602 })
1603 })
1604 .collect(),
1605 MarketListSourceKind::TownStorage { building_id } => {
1606 let Some(panel) = &self.market_panel else {
1607 return Vec::new();
1608 };
1609 let Some(vault) = panel
1610 .list_vaults
1611 .iter()
1612 .find(|v| &v.building_id == building_id)
1613 else {
1614 return Vec::new();
1615 };
1616 vault
1617 .contents
1618 .iter()
1619 .filter(|s| self.stack_is_market_listable(s))
1620 .filter_map(|s| {
1621 let id = s.item_instance_id?;
1622 Some(StoragePickOption {
1623 item_instance_id: id,
1624 label: storage_stack_label(s),
1625 quantity: s.quantity,
1626 category: s
1627 .category
1628 .clone()
1629 .or_else(|| {
1630 self.inventory_item_category(&s.template_id)
1631 .map(str::to_string)
1632 })
1633 .unwrap_or_default(),
1634 })
1635 })
1636 .collect()
1637 }
1638 };
1639 opts.retain(|o| {
1640 if !list_label_matches(&o.label, filter) {
1641 return false;
1642 }
1643 if let Some(group) = cat_filter {
1644 inventory_category_group(&o.category).0 == group
1645 } else {
1646 true
1647 }
1648 });
1649 opts
1650 }
1651
1652 fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
1653 if crate::currency::is_currency(&stack.template_id) {
1654 return false;
1655 }
1656 if let Some(flag) = stack.listable {
1657 return flag;
1658 }
1659 if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
1660 return hint.listable;
1661 }
1662 let cat = stack
1663 .category
1664 .as_deref()
1665 .or_else(|| self.inventory_item_category(&stack.template_id))
1666 .unwrap_or("");
1667 category_default_listable(cat)
1668 }
1669
1670 pub fn market_available_category_groups(&self) -> Vec<&'static str> {
1672 let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
1673 match &self.market_ui_mode {
1674 MarketUiMode::ListPick { source, .. } => {
1675 let raw: Vec<_> = match source {
1676 MarketListSourceKind::Person => self
1677 .person_rows()
1678 .into_iter()
1679 .filter(|r| r.depth == 0)
1680 .filter(|r| self.stack_is_market_listable(&r.stack))
1681 .filter(|r| list_label_matches(&storage_stack_label(&r.stack), &self.market_filter))
1682 .map(|r| {
1683 r.stack
1684 .category
1685 .clone()
1686 .or_else(|| {
1687 self.inventory_item_category(&r.stack.template_id)
1688 .map(str::to_string)
1689 })
1690 .unwrap_or_default()
1691 })
1692 .collect(),
1693 MarketListSourceKind::TownStorage { building_id } => self
1694 .market_panel
1695 .as_ref()
1696 .and_then(|p| {
1697 p.list_vaults
1698 .iter()
1699 .find(|v| &v.building_id == building_id)
1700 })
1701 .map(|vault| {
1702 vault
1703 .contents
1704 .iter()
1705 .filter(|s| self.stack_is_market_listable(s))
1706 .filter(|s| {
1707 list_label_matches(&storage_stack_label(s), &self.market_filter)
1708 })
1709 .map(|s| {
1710 s.category
1711 .clone()
1712 .or_else(|| {
1713 self.inventory_item_category(&s.template_id)
1714 .map(str::to_string)
1715 })
1716 .unwrap_or_default()
1717 })
1718 .collect::<Vec<_>>()
1719 })
1720 .unwrap_or_default(),
1721 };
1722 for category in raw {
1723 let (label, ord) = inventory_category_group(&category);
1724 seen.insert(ord, label);
1725 }
1726 }
1727 _ => {
1728 if let Some(panel) = &self.market_panel {
1729 for listing in &panel.listings {
1730 if !list_label_matches(&listing.display_name, &self.market_filter)
1731 && !list_label_matches(&listing.seller_label, &self.market_filter)
1732 {
1733 continue;
1734 }
1735 let (label, ord) = inventory_category_group(&listing.category);
1736 seen.insert(ord, label);
1737 }
1738 }
1739 }
1740 }
1741 seen.into_values().collect()
1742 }
1743
1744 pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
1746 let Some(panel) = &self.market_panel else {
1747 return Vec::new();
1748 };
1749 let filter = self.market_filter.as_str();
1750 let cat_filter = self.market_category_filter;
1751 panel
1752 .listings
1753 .iter()
1754 .enumerate()
1755 .filter(|(_, listing)| {
1756 if !list_label_matches(&listing.display_name, filter)
1757 && !list_label_matches(&listing.seller_label, filter)
1758 && !list_label_matches(&listing.template_id, filter)
1759 {
1760 return false;
1761 }
1762 if let Some(group) = cat_filter {
1763 inventory_category_group(&listing.category).0 == group
1764 } else {
1765 true
1766 }
1767 })
1768 .map(|(i, _)| i)
1769 .collect()
1770 }
1771
1772 pub fn clear_harvest_state(&mut self) {
1773 self.harvest_in_progress = false;
1774 self.harvest_started_at = None;
1775 }
1776
1777 fn harvest_state_stale(&self) -> bool {
1778 match self.harvest_started_at {
1779 Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
1780 None => self.harvest_in_progress,
1781 }
1782 }
1783
1784 pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
1785 self.player.as_ref().and_then(|p| p.vitals)
1786 }
1787
1788 pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
1789 let materials_ok = blueprint.inputs.iter().all(|input| {
1790 self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
1791 });
1792 let tools_ok = blueprint
1793 .required_tools
1794 .iter()
1795 .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
1796 let station_ok = match blueprint.station.as_deref() {
1797 None | Some("hand") => true,
1798 Some(tag) => self.player_at_station_tag(tag),
1799 };
1800 materials_ok && tools_ok && station_ok
1801 }
1802
1803 pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
1804 if !self.can_craft_blueprint(blueprint) {
1805 return 0;
1806 }
1807 let mut limit = u32::MAX;
1808 for input in &blueprint.inputs {
1809 if input.quantity == 0 {
1810 continue;
1811 }
1812 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1813 limit = limit.min(have / input.quantity);
1814 }
1815 for tool in &blueprint.required_tools {
1816 if tool.consumed {
1817 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1818 limit = limit.min(have);
1819 }
1820 }
1821 let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
1822 if CRAFT_STAMINA_COST > 0.0 {
1823 limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
1824 }
1825 limit
1826 }
1827
1828 pub fn clamp_craft_batch_quantity(&mut self) {
1829 let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
1830 self.craft_batch_quantity = 1;
1831 return;
1832 };
1833 let max = self.max_craft_batches(bp).max(1);
1834 self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
1835 }
1836
1837 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
1838 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1839 return;
1840 };
1841 let max = self.max_craft_batches(&bp).max(1);
1842 let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
1843 self.craft_batch_quantity = next as u32;
1844 }
1845
1846 pub fn craft_batch_set_max(&mut self) {
1847 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1848 return;
1849 };
1850 let max = self.max_craft_batches(&bp);
1851 self.craft_batch_quantity = if max == 0 { 1 } else { max };
1852 }
1853
1854 pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
1855 let preserve_ui = self.show_shop_menu;
1856 let tab = self.shop_tab;
1857 let index = self.shop_menu_index;
1858 let qty = self.shop_quantity;
1859
1860 self.show_shop_menu = true;
1861 self.bank_panel = None;
1862 self.show_craft_menu = false;
1863 self.show_inventory_menu = false;
1864 self.show_stats = false;
1865 if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
1866 self.npc_verb_target = Some(catalog.npc_id.clone());
1867 }
1868 self.shop_catalog = Some(catalog);
1869
1870 if preserve_ui {
1871 self.shop_tab = tab;
1872 self.shop_menu_index = index;
1873 self.shop_quantity = qty;
1874 } else {
1875 self.shop_tab = ShopTab::Buy;
1876 self.shop_menu_index = 0;
1877 self.shop_quantity = 1;
1878 self.clear_shop_trade_log();
1879 }
1880 self.show_npc_verb_menu = false;
1881 self.clamp_shop_selection();
1882 }
1883
1884 pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
1885 let same_teller = self
1886 .bank_panel
1887 .as_ref()
1888 .is_some_and(|p| p.npc_id == panel.npc_id);
1889 self.bank_panel = Some(panel);
1890 self.storage_panel = None;
1891 self.market_panel = None;
1892 self.shop_catalog = None;
1893 self.show_shop_menu = false;
1894 self.show_craft_menu = false;
1895 self.show_inventory_menu = false;
1896 self.show_stats = false;
1897 self.show_npc_verb_menu = false;
1898 self.show_npc_chat = false;
1899 self.npc_chat = None;
1900 if !same_teller {
1901 self.bank_menu_index = 0;
1902 self.bank_ui_mode = BankUiMode::Menu;
1903 }
1904 if let Some(panel) = &self.bank_panel {
1905 if self.npc_verb_target.is_none() {
1906 self.npc_verb_target = Some(panel.npc_id.clone());
1907 }
1908 }
1909 }
1910
1911 pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
1912 let same_manager = self
1913 .storage_panel
1914 .as_ref()
1915 .is_some_and(|p| p.npc_id == panel.npc_id);
1916 self.storage_panel = Some(panel);
1917 self.bank_panel = None;
1918 self.market_panel = None;
1919 self.bank_ui_mode = BankUiMode::Menu;
1920 self.shop_catalog = None;
1921 self.show_shop_menu = false;
1922 self.show_craft_menu = false;
1923 self.show_inventory_menu = false;
1924 self.show_stats = false;
1925 self.show_npc_verb_menu = false;
1926 self.show_npc_chat = false;
1927 self.npc_chat = None;
1928 if !same_manager {
1929 self.storage_menu_index = 0;
1930 self.storage_ui_mode = StorageUiMode::Menu;
1931 } else {
1932 self.clamp_storage_pick_index();
1933 }
1934 if let Some(panel) = &self.storage_panel {
1935 if self.npc_verb_target.is_none() {
1936 self.npc_verb_target = Some(panel.npc_id.clone());
1937 }
1938 }
1939 }
1940
1941 pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
1942 self.market_panel = Some(panel);
1943 self.bank_panel = None;
1944 self.storage_panel = None;
1945 self.shop_catalog = None;
1946 self.show_shop_menu = false;
1947 self.show_craft_menu = false;
1948 self.show_inventory_menu = false;
1949 self.show_stats = false;
1950 self.show_npc_verb_menu = false;
1951 self.show_npc_chat = false;
1952 self.npc_chat = None;
1953 self.market_menu_index = 0;
1954 self.market_buy_confirm = None;
1955 self.market_ui_mode = MarketUiMode::Browse;
1956 self.market_filter.clear();
1957 self.market_filter_focused = false;
1958 self.market_category_filter = None;
1959 if let Some(panel) = &self.market_panel {
1960 if self.npc_verb_target.is_none() {
1961 self.npc_verb_target = Some(panel.npc_id.clone());
1962 }
1963 }
1964 }
1965
1966 pub fn clear_market_panel(&mut self) {
1967 self.market_panel = None;
1968 self.market_menu_index = 0;
1969 self.market_buy_confirm = None;
1970 self.market_ui_mode = MarketUiMode::Browse;
1971 self.market_filter.clear();
1972 self.market_filter_focused = false;
1973 self.market_category_filter = None;
1974 }
1975
1976 pub fn clear_bank_panel(&mut self) {
1977 self.bank_panel = None;
1978 self.bank_menu_index = 0;
1979 self.bank_ui_mode = BankUiMode::Menu;
1980 }
1981
1982 pub fn clear_storage_panel(&mut self) {
1983 self.storage_panel = None;
1984 self.storage_menu_index = 0;
1985 self.storage_ui_mode = StorageUiMode::Menu;
1986 }
1987
1988 fn clamp_storage_pick_index(&mut self) {
1989 match &self.storage_ui_mode {
1990 StorageUiMode::StorePick { index } => {
1991 let n = self.storage_store_options().len();
1992 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1993 self.storage_ui_mode = StorageUiMode::StorePick { index: next };
1994 }
1995 StorageUiMode::TakePick { index } => {
1996 let n = self.storage_vault_options().len();
1997 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1998 self.storage_ui_mode = StorageUiMode::TakePick { index: next };
1999 }
2000 StorageUiMode::ShipPick {
2001 dest_building_id,
2002 dest_label,
2003 index,
2004 } => {
2005 let n = self.storage_vault_options().len();
2006 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2007 self.storage_ui_mode = StorageUiMode::ShipPick {
2008 dest_building_id: dest_building_id.clone(),
2009 dest_label: dest_label.clone(),
2010 index: next,
2011 };
2012 }
2013 StorageUiMode::Menu
2014 | StorageUiMode::StoreAmount { .. }
2015 | StorageUiMode::TakeAmount { .. }
2016 | StorageUiMode::ShipAmount { .. } => {}
2017 }
2018 }
2019
2020 pub fn shop_list_len(&self) -> usize {
2021 let Some(catalog) = &self.shop_catalog else {
2022 return 0;
2023 };
2024 match self.shop_tab {
2025 ShopTab::Buy => catalog.sells.len(),
2026 ShopTab::Sell => catalog.buys.len(),
2027 }
2028 }
2029
2030 pub fn shop_menu_move(&mut self, delta: i32) {
2031 let n = self.shop_list_len();
2032 if n == 0 {
2033 return;
2034 }
2035 let idx = self.shop_menu_index as i32;
2036 let next = (idx + delta).rem_euclid(n as i32);
2037 self.shop_menu_index = next as usize;
2038 self.clamp_shop_quantity();
2039 }
2040
2041 pub fn shop_quantity_adjust(&mut self, delta: i32) {
2042 let max = self.shop_quantity_max();
2043 if max == 0 {
2044 self.shop_quantity = 0;
2045 return;
2046 }
2047 let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
2048 self.shop_quantity = next as u32;
2049 }
2050
2051 pub(crate) fn clamp_shop_selection(&mut self) {
2052 let n = self.shop_list_len();
2053 if n == 0 {
2054 self.shop_menu_index = 0;
2055 } else {
2056 self.shop_menu_index = self.shop_menu_index.min(n - 1);
2057 }
2058 self.clamp_shop_quantity();
2059 }
2060
2061 fn shop_quantity_max(&self) -> u32 {
2062 let Some(catalog) = &self.shop_catalog else {
2063 return 1;
2064 };
2065 match self.shop_tab {
2066 ShopTab::Buy => {
2067 if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
2068 if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
2069 return 1;
2070 }
2071 }
2072 99
2073 }
2074 ShopTab::Sell => catalog
2075 .buys
2076 .get(self.shop_menu_index)
2077 .map(|l| l.quantity)
2078 .unwrap_or(0),
2079 }
2080 }
2081
2082 pub fn shop_quantity_set_max(&mut self) {
2083 self.shop_quantity = self.shop_quantity_max();
2084 }
2085
2086 fn clamp_shop_quantity(&mut self) {
2087 let max = self.shop_quantity_max();
2088 if max == 0 {
2089 self.shop_quantity = 0;
2090 } else {
2091 self.shop_quantity = self.shop_quantity.max(1).min(max);
2092 }
2093 }
2094
2095 pub fn player_at_station_tag(&self, tag: &str) -> bool {
2096 let Some(id) = self.effective_inside_building() else {
2097 return false;
2098 };
2099 self.buildings
2100 .iter()
2101 .find(|b| b.id == id)
2102 .is_some_and(|b| b.tags.iter().any(|t| t == tag))
2103 }
2104
2105 pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
2107 if self.can_craft_blueprint(blueprint) {
2108 return None;
2109 }
2110 let mut missing = Vec::new();
2111 for input in &blueprint.inputs {
2112 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2113 if have < input.quantity {
2114 missing.push(format!(
2115 "{}×{} (have {have})",
2116 input.quantity, input.template_id
2117 ));
2118 }
2119 }
2120 for tool in &blueprint.required_tools {
2121 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2122 if have < 1 {
2123 missing.push(format!("tool: {}", tool.item));
2124 }
2125 }
2126 if let Some(station) = blueprint.station.as_deref() {
2127 if station != "hand" && !self.player_at_station_tag(station) {
2128 missing.push(format!("station: {station} (enter building)"));
2129 }
2130 }
2131 if missing.is_empty() {
2132 None
2133 } else {
2134 Some(missing.join(", "))
2135 }
2136 }
2137
2138 pub fn player_entity(&self) -> Option<&EntityState> {
2139 self.player
2140 .as_ref()
2141 .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
2142 }
2143
2144 pub fn apply_client_ui_prefs(&mut self) {
2146 let cfg = crate::client_config::ClientConfig::load();
2147 if let Some(hidden) = cfg.hud_log_hidden {
2148 self.hud_log_hidden = hidden;
2149 }
2150 if let Some(compact) = cfg.workers_menu_compact {
2151 self.workers_menu_compact = compact;
2152 }
2153 }
2154
2155 pub fn player_position(&self) -> (f32, f32) {
2156 let (x, y, _) = self.player_position_with_z();
2157 (x, y)
2158 }
2159
2160 pub fn player_position_with_z(&self) -> (f32, f32, f32) {
2161 if let Some(p) = self.player_entity() {
2162 (
2163 p.transform.position.x,
2164 p.transform.position.y,
2165 p.transform.position.z,
2166 )
2167 } else {
2168 (0.0, 0.0, 0.0)
2169 }
2170 }
2171
2172 pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
2173 let mut rows: Vec<(String, u32, String)> = self
2174 .inventory
2175 .iter()
2176 .filter(|(_, q)| **q > 0)
2177 .map(|(id, qty)| {
2178 let label = self
2179 .inventory_hints
2180 .get(id)
2181 .map(|h| h.display_name.clone())
2182 .unwrap_or_else(|| id.clone());
2183 (id.clone(), *qty, label)
2184 })
2185 .collect();
2186 rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
2187 rows
2188 }
2189
2190 pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
2191 self.inventory_hints
2192 .get(template_id)
2193 .map(|h| h.category.as_str())
2194 .filter(|c| !c.is_empty())
2195 }
2196
2197 pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
2198 stack
2199 .props
2200 .get("grants_item_status_effect")
2201 .map(|s| !s.is_empty())
2202 .unwrap_or(false)
2203 }
2204
2205 pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
2206 stack
2207 .props
2208 .get("grants_item_status_effect")
2209 .map(String::as_str)
2210 .filter(|s| !s.is_empty())
2211 }
2212
2213 pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
2214 stack
2215 .props
2216 .get("grants_item_status_mode")
2217 .map(String::as_str)
2218 .unwrap_or("on_hit")
2219 }
2220
2221 pub fn grant_target_options(
2223 &self,
2224 grant: &flatland_protocol::ItemStack,
2225 ) -> Vec<GrantTargetOption> {
2226 let mode = Self::grant_mode(grant);
2227 let grant_tags: Vec<&str> = grant
2228 .props
2229 .get("grants_item_status_tags")
2230 .map(|s| {
2231 s.split(',')
2232 .map(str::trim)
2233 .filter(|t| !t.is_empty())
2234 .collect()
2235 })
2236 .unwrap_or_default();
2237 let grant_id = grant.item_instance_id;
2238 let mut out = Vec::new();
2239 let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
2240 let Some(iid) = stack.item_instance_id else {
2241 return;
2242 };
2243 if Some(iid) == grant_id {
2244 return;
2245 }
2246 if stack.props.get("enchantable").map(String::as_str) == Some("0") {
2247 return;
2248 }
2249 if !grant_target_matches_mode(stack, mode) {
2250 return;
2251 }
2252 if !grant_tags_match(stack, &grant_tags) {
2253 return;
2254 }
2255 let name = stack
2256 .display_name
2257 .clone()
2258 .unwrap_or_else(|| stack.template_id.clone());
2259 let bindings = if stack.status_bindings.is_empty() {
2260 String::new()
2261 } else {
2262 format!(
2263 " · {}",
2264 stack
2265 .status_bindings
2266 .iter()
2267 .map(|b| b.effect_id.as_str())
2268 .collect::<Vec<_>>()
2269 .join(", ")
2270 )
2271 };
2272 out.push(GrantTargetOption {
2273 label: format!("{where_label}: {name}{bindings}"),
2274 target_instance_id: iid,
2275 });
2276 };
2277 fn walk(
2278 stacks: &[flatland_protocol::ItemStack],
2279 where_label: &str,
2280 push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
2281 ) {
2282 for s in stacks {
2283 push(s, where_label);
2284 if !s.contents.is_empty() {
2285 let nested = format!(
2286 "{where_label}/{}",
2287 s.display_name
2288 .as_deref()
2289 .unwrap_or(s.template_id.as_str())
2290 );
2291 walk(&s.contents, &nested, push);
2292 }
2293 }
2294 }
2295 walk(&self.inventory_stacks, "Bag", &mut push);
2296 for (slot, stack) in &self.worn {
2297 push(stack, body_slot_label(*slot));
2298 let nest = format!(
2299 "{}/{}",
2300 body_slot_label(*slot),
2301 stack
2302 .display_name
2303 .as_deref()
2304 .unwrap_or(stack.template_id.as_str())
2305 );
2306 walk(&stack.contents, &nest, &mut push);
2307 }
2308 out
2309 }
2310
2311 pub fn item_base_mass(&self, template_id: &str) -> f32 {
2312 self.inventory_hints
2313 .get(template_id)
2314 .and_then(|h| h.base_mass)
2315 .unwrap_or(0.5)
2316 }
2317
2318 pub fn item_base_volume(&self, template_id: &str) -> f32 {
2319 self.inventory_hints
2320 .get(template_id)
2321 .and_then(|h| h.base_volume)
2322 .unwrap_or(1.0)
2323 }
2324
2325 pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
2326 let unit = stack
2327 .base_mass
2328 .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
2329 unit * stack.quantity as f32
2330 }
2331
2332 fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
2333 let unit = stack.base_volume.unwrap_or(1.0);
2334 unit * stack.quantity as f32
2335 + stack
2336 .contents
2337 .iter()
2338 .map(Self::stack_tree_volume)
2339 .sum::<f32>()
2340 }
2341
2342 fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
2343 contents.iter().map(Self::stack_tree_volume).sum()
2344 }
2345
2346 fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
2347 self.inventory_hints
2348 .get(template_id)
2349 .and_then(|h| h.capacity_volume)
2350 .filter(|c| *c > 0.0)
2351 }
2352
2353 fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
2354 stack
2355 .capacity_volume
2356 .filter(|c| *c > 0.0)
2357 .or_else(|| self.template_capacity_volume(&stack.template_id))
2358 }
2359
2360 pub fn container_volume_label(&self, row: &InventoryRow) -> String {
2362 let Some((used, cap)) = self.container_volume_stats(row) else {
2363 return String::new();
2364 };
2365 let free = (cap - used).max(0.0);
2366 format!(" vol {used:.0}/{cap:.0} ({free:.0} free)")
2367 }
2368
2369 fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
2370 if row.is_chest_shell {
2371 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
2372 return None;
2373 };
2374 let chest = self
2375 .placed_containers
2376 .iter()
2377 .find(|c| c.id == *container_id)?;
2378 let cap = self
2379 .stack_capacity_volume(&row.stack)
2380 .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
2381 let used = if chest.accessible {
2382 Self::contents_used_volume(&chest.contents)
2383 } else {
2384 0.0
2385 };
2386 return Some((used, cap));
2387 }
2388
2389 let cap = self.stack_capacity_volume(&row.stack)?;
2390 let used = Self::contents_used_volume(&row.stack.contents);
2391 Some((used, cap))
2392 }
2393
2394 pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
2395 if row.is_chest_shell {
2396 return true;
2397 }
2398 if row.is_equip_shell {
2399 return self.inventory_item_category(&row.stack.template_id) == Some("container");
2400 }
2401 self.inventory_item_category(&row.stack.template_id) == Some("container")
2402 || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
2403 }
2404
2405 fn container_stack_for(
2406 &self,
2407 location: &flatland_protocol::InventoryLocation,
2408 parent_instance_id: Option<uuid::Uuid>,
2409 ) -> Option<flatland_protocol::ItemStack> {
2410 match location {
2411 flatland_protocol::InventoryLocation::Root => {
2412 let pid = parent_instance_id?;
2413 self.find_stack_by_instance(&self.inventory_stacks, pid)
2414 }
2415 flatland_protocol::InventoryLocation::Worn { slot } => {
2416 let worn = self.worn.get(slot)?;
2417 if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
2418 Some(worn.clone())
2419 } else {
2420 self.find_stack_by_instance(&worn.contents, parent_instance_id?)
2421 }
2422 }
2423 flatland_protocol::InventoryLocation::Placed { container_id } => {
2424 let chest = self
2425 .placed_containers
2426 .iter()
2427 .find(|c| c.id == *container_id)?;
2428 if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
2429 Some(flatland_protocol::ItemStack {
2430 template_id: chest.template_id.clone(),
2431 quantity: 1,
2432 item_instance_id: chest.item_instance_id,
2433 props: Default::default(),
2434 status_bindings: Vec::new(),
2435 contents: chest.contents.clone(),
2436 display_name: Some(chest.display_name.clone()),
2437 category: Some("container".into()),
2438 capacity_volume: self
2439 .inventory_hints
2440 .get(&chest.template_id)
2441 .and_then(|h| h.capacity_volume),
2442 worker_lodging_capacity: chest.worker_lodging_capacity,
2443 ..Default::default()
2444 })
2445 } else {
2446 self.find_stack_by_instance(&chest.contents, parent_instance_id?)
2447 }
2448 }
2449 flatland_protocol::InventoryLocation::Keychain => None,
2450 flatland_protocol::InventoryLocation::WhisperPouch => None,
2451 }
2452 }
2453
2454 fn find_stack_by_instance(
2455 &self,
2456 stacks: &[flatland_protocol::ItemStack],
2457 instance_id: uuid::Uuid,
2458 ) -> Option<flatland_protocol::ItemStack> {
2459 for stack in stacks {
2460 if stack.item_instance_id == Some(instance_id) {
2461 return Some(stack.clone());
2462 }
2463 if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
2464 return Some(found);
2465 }
2466 }
2467 None
2468 }
2469
2470 pub fn max_movable_to(
2472 &self,
2473 template_id: &str,
2474 stack_qty: u32,
2475 from: &flatland_protocol::InventoryLocation,
2476 to: &flatland_protocol::InventoryLocation,
2477 parent_instance_id: Option<uuid::Uuid>,
2478 ) -> u32 {
2479 let unit_vol = self.item_base_volume(template_id);
2480 let unit_mass = self.item_base_mass(template_id);
2481 let mut limit = stack_qty;
2482
2483 if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2484 let cap = parent
2485 .capacity_volume
2486 .or_else(|| {
2487 self.inventory_hints
2488 .get(&parent.template_id)
2489 .and_then(|h| h.capacity_volume)
2490 })
2491 .unwrap_or(0.0);
2492 if cap > 0.0 && unit_vol > 0.0 {
2493 let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2494 limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2495 }
2496 }
2497
2498 let to_person = matches!(
2499 to,
2500 flatland_protocol::InventoryLocation::Root
2501 | flatland_protocol::InventoryLocation::Worn { .. }
2502 );
2503 let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2504 if to_person && from_placed && unit_mass > 0.0 {
2505 let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2506 if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2507 limit = 0;
2508 } else {
2509 limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2510 }
2511 }
2512
2513 limit.max(0).min(stack_qty)
2514 }
2515
2516 pub fn move_picker_max_at_selection(&self) -> u32 {
2517 let Some(picker) = &self.move_picker else {
2518 return 1;
2519 };
2520 let Some(opt) = picker.options.get(self.move_picker_index) else {
2521 return picker.stack_quantity;
2522 };
2523 match &opt.kind {
2524 MoveOptionKind::Cancel
2525 | MoveOptionKind::Drop
2526 | MoveOptionKind::Use
2527 | MoveOptionKind::GrantApply
2528 | MoveOptionKind::SellPlotToCrown { .. }
2529 | MoveOptionKind::PickupPlaced { .. }
2530 | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
2531 MoveOptionKind::Move {
2532 location,
2533 parent_instance_id,
2534 } => self.max_movable_to(
2535 &picker.template_id,
2536 picker.stack_quantity,
2537 &picker.from,
2538 location,
2539 *parent_instance_id,
2540 ),
2541 }
2542 }
2543
2544 pub fn clamp_move_picker_quantity(&mut self) {
2545 let max = self.move_picker_max_at_selection();
2546 if let Some(picker) = &mut self.move_picker {
2547 if max == 0 {
2548 picker.quantity = 1;
2549 } else {
2550 picker.quantity = picker.quantity.clamp(1, max);
2551 }
2552 }
2553 }
2554
2555 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2556 let max = self.move_picker_max_at_selection().max(1);
2557 if let Some(picker) = &mut self.move_picker {
2558 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2559 picker.quantity = next as u32;
2560 }
2561 }
2562
2563 pub fn move_picker_set_quantity_max(&mut self) {
2564 let max = self.move_picker_max_at_selection();
2565 if let Some(picker) = &mut self.move_picker {
2566 picker.quantity = if max == 0 {
2567 1
2568 } else {
2569 max.min(picker.stack_quantity)
2570 };
2571 }
2572 }
2573
2574 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2575 if let Some(picker) = &mut self.destroy_picker {
2576 let max = picker.stack_quantity.max(1);
2577 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2578 picker.quantity = next as u32;
2579 }
2580 }
2581
2582 pub fn destroy_picker_set_quantity_max(&mut self) {
2583 if let Some(picker) = &mut self.destroy_picker {
2584 picker.quantity = picker.stack_quantity.max(1);
2585 }
2586 }
2587
2588 pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2589 let have = self.inventory.get(template_id).copied().unwrap_or(0);
2590 (have, have >= need)
2591 }
2592
2593 pub fn currency_display(&self) -> String {
2594 crate::currency::currency_line(&self.inventory)
2595 }
2596
2597 pub fn in_shallow_water(&self) -> bool {
2599 let (px, py) = self.player_position();
2600 self.terrain_at(px, py)
2601 .is_some_and(|k| k == TerrainKindView::ShallowWater)
2602 }
2603
2604 pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
2605 self.terrain_zone_at(x, y).map(|z| z.kind)
2606 }
2607
2608 pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
2610 use std::cell::RefCell;
2611
2612 const CHUNK: i32 = 8;
2613 thread_local! {
2614 static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
2615 RefCell::new(None);
2616 }
2617
2618 let zones = &self.terrain_zones;
2619 if zones.is_empty() {
2620 return None;
2621 }
2622 if zones.len() <= 48 {
2623 return zones
2624 .iter()
2625 .enumerate()
2626 .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
2627 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
2628 .map(|(_, z)| z);
2629 }
2630
2631 let ptr = zones.as_ptr();
2632 let len = zones.len();
2633 INDEX.with(|cell| {
2634 let mut slot = cell.borrow_mut();
2635 let stale = match slot.as_ref() {
2636 Some((p, l, _)) => *p != ptr || *l != len,
2637 None => true,
2638 };
2639 if stale {
2640 let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
2641 std::collections::HashMap::new();
2642 for (zi, z) in zones.iter().enumerate() {
2643 let x0 = z.x0.min(z.x1).floor() as i32;
2644 let y0 = z.y0.min(z.y1).floor() as i32;
2645 let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
2646 let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
2647 let cx0 = x0.div_euclid(CHUNK);
2648 let cy0 = y0.div_euclid(CHUNK);
2649 let cx1 = x1.div_euclid(CHUNK);
2650 let cy1 = y1.div_euclid(CHUNK);
2651 for cy in cy0..=cy1 {
2652 for cx in cx0..=cx1 {
2653 chunks.entry((cx, cy)).or_default().push(zi);
2654 }
2655 }
2656 }
2657 *slot = Some((ptr, len, chunks));
2658 }
2659 let chunks = &slot.as_ref().expect("index").2;
2660 let cx = (x.floor() as i32).div_euclid(CHUNK);
2661 let cy = (y.floor() as i32).div_euclid(CHUNK);
2662 let mut best: Option<(usize, &TerrainZoneView)> = None;
2663 if let Some(list) = chunks.get(&(cx, cy)) {
2664 for &zi in list {
2665 let Some(z) = zones.get(zi) else { continue };
2666 if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
2667 continue;
2668 }
2669 best = match best {
2670 None => Some((zi, z)),
2671 Some((bi, bz)) => {
2672 if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
2673 Some((zi, z))
2674 } else {
2675 Some((bi, bz))
2676 }
2677 }
2678 };
2679 }
2680 }
2681 best.map(|(_, z)| z)
2682 })
2683 }
2684
2685 pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
2687 self.terrain_zone_at(x, y)
2688 .map(|z| z.elevation)
2689 .unwrap_or(0.0)
2690 }
2691
2692 pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
2694 const TOL: f32 = 0.35;
2695 let mut levels = vec![self.elevation_at(x, y)];
2696 for p in &self.z_platforms {
2697 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2698 levels.push(p.z);
2699 }
2700 }
2701 levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2702 levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
2703 levels
2704 }
2705
2706 pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
2707 const TOL: f32 = 0.35;
2708 self.walkable_levels_at(x, y)
2709 .iter()
2710 .any(|&l| (l - z).abs() <= TOL)
2711 }
2712
2713 pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
2714 let mut top = self.elevation_at(x, y);
2715 for p in &self.z_platforms {
2716 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2717 top = top.max(p.z);
2718 }
2719 }
2720 top
2721 }
2722
2723 pub fn effective_inside_building(&self) -> Option<String> {
2725 self.player_entity().and_then(|p| p.inside_building.clone())
2726 }
2727
2728 pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
2729 self.inventory_stacks = stacks.to_vec();
2730 self.inventory.clear();
2731 self.inventory_hints.clear();
2732 fn walk(
2733 stacks: &[flatland_protocol::ItemStack],
2734 inventory: &mut std::collections::HashMap<String, u32>,
2735 hints: &mut std::collections::HashMap<String, InventoryHint>,
2736 ) {
2737 for stack in stacks {
2738 *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
2739 if stack.display_name.is_some()
2740 || stack.category.is_some()
2741 || stack.base_mass.is_some()
2742 || stack.base_volume.is_some()
2743 {
2744 hints.insert(
2745 stack.template_id.clone(),
2746 InventoryHint {
2747 display_name: stack
2748 .display_name
2749 .clone()
2750 .unwrap_or_else(|| stack.template_id.clone()),
2751 category: stack.category.clone().unwrap_or_default(),
2752 base_mass: stack.base_mass,
2753 base_volume: stack.base_volume,
2754 capacity_volume: stack.capacity_volume,
2755 stackable: stack.stackable.unwrap_or(true),
2756 listable: stack.listable.unwrap_or_else(|| {
2757 category_default_listable(
2758 stack.category.as_deref().unwrap_or(""),
2759 )
2760 }),
2761 },
2762 );
2763 }
2764 walk(&stack.contents, inventory, hints);
2765 }
2766 }
2767 walk(stacks, &mut self.inventory, &mut self.inventory_hints);
2768 for item in self.worn.values() {
2770 walk(
2771 std::slice::from_ref(item),
2772 &mut self.inventory,
2773 &mut self.inventory_hints,
2774 );
2775 }
2776 }
2777
2778 pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
2781 let subtract_items =
2782 notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
2783 for stack in ¬ice.inventory_delta {
2784 if stack.quantity == 0 {
2785 continue;
2786 }
2787 if subtract_items {
2788 crate::currency::drain_template_stacks(
2789 &mut self.inventory_stacks,
2790 &stack.template_id,
2791 stack.quantity,
2792 );
2793 continue;
2794 }
2795 let stackable = self
2796 .inventory_hints
2797 .get(&stack.template_id)
2798 .map(|h| h.stackable)
2799 .or(stack.stackable)
2800 .unwrap_or(true);
2801 if stackable {
2802 if let Some(existing) = self
2803 .inventory_stacks
2804 .iter_mut()
2805 .find(|s| s.template_id == stack.template_id)
2806 {
2807 existing.quantity = existing.quantity.saturating_add(stack.quantity);
2808 if stack.display_name.is_some() {
2809 existing.display_name = stack.display_name.clone();
2810 }
2811 if stack.category.is_some() {
2812 existing.category = stack.category.clone();
2813 }
2814 continue;
2815 }
2816 }
2817 self.inventory_stacks.push(stack.clone());
2818 }
2819 if notice.coins_delta != 0 {
2820 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
2821 }
2822 if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
2823 let stacks = self.inventory_stacks.clone();
2824 self.sync_inventory_from_stacks(&stacks);
2825 }
2826 self.record_shop_trade_notice(notice);
2827 }
2828
2829 pub fn worn_rows(&self) -> Vec<InventoryRow> {
2834 let mut rows = Vec::new();
2835 for (slot, item) in &self.worn {
2836 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2837 rows.push(InventoryRow {
2838 depth: 0,
2839 stack: item.clone(),
2840 from: from.clone(),
2841 from_parent_instance_id: None,
2842 is_equip_shell: true,
2843 is_chest_shell: false,
2844 section: InventorySection::Worn,
2845 });
2846 for child in &item.contents {
2847 push_inventory_rows(
2848 &mut rows,
2849 1,
2850 child,
2851 &from,
2852 item.item_instance_id,
2853 InventorySection::Worn,
2854 );
2855 }
2856 }
2857 rows
2858 }
2859
2860 pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
2862 self.inventory_stacks
2863 .iter()
2864 .filter_map(|stack| {
2865 let item_instance_id = stack.item_instance_id?;
2866 let label = stack
2867 .display_name
2868 .clone()
2869 .unwrap_or_else(|| stack.template_id.clone());
2870 let label = if stack.quantity > 1 {
2871 format!("{label} ×{}", stack.quantity)
2872 } else {
2873 label
2874 };
2875 Some(WorkerGiveOption {
2876 item_instance_id,
2877 label,
2878 quantity: stack.quantity,
2879 template_id: stack.template_id.clone(),
2880 })
2881 })
2882 .collect()
2883 }
2884
2885 pub fn teachable_blueprint_options(
2887 &self,
2888 worker: &flatland_protocol::HiredWorkerView,
2889 ) -> Vec<WorkerTeachOption> {
2890 let copper = crate::currency::copper_from_counts(&self.inventory);
2891 let mut options: Vec<WorkerTeachOption> = self
2892 .blueprints
2893 .iter()
2894 .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
2895 .map(|bp| {
2896 let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
2897 let cost = bp.worker_train_copper;
2898 WorkerTeachOption {
2899 blueprint_id: bp.id.clone(),
2900 label: if bp.label.is_empty() {
2901 bp.id.clone()
2902 } else {
2903 bp.label.clone()
2904 },
2905 cost_copper: cost,
2906 min_level,
2907 worker_level: worker.level,
2908 can_afford: copper >= cost,
2909 level_ok: worker.level >= min_level,
2910 }
2911 })
2912 .collect();
2913 options.sort_by(|a, b| a.label.cmp(&b.label));
2914 options
2915 }
2916
2917 pub fn person_rows(&self) -> Vec<InventoryRow> {
2920 self.person_rows_filtered("")
2921 }
2922
2923 pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2924 let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
2925 roots.sort_by(|a, b| {
2926 let ca = a
2927 .category
2928 .as_deref()
2929 .or_else(|| self.inventory_item_category(&a.template_id))
2930 .unwrap_or("");
2931 let cb = b
2932 .category
2933 .as_deref()
2934 .or_else(|| self.inventory_item_category(&b.template_id))
2935 .unwrap_or("");
2936 let ga = inventory_category_group(ca).1;
2937 let gb = inventory_category_group(cb).1;
2938 ga.cmp(&gb).then_with(|| {
2939 let na = a
2940 .display_name
2941 .as_deref()
2942 .unwrap_or(a.template_id.as_str());
2943 let nb = b
2944 .display_name
2945 .as_deref()
2946 .unwrap_or(b.template_id.as_str());
2947 na.cmp(nb)
2948 })
2949 });
2950 let mut rows = Vec::new();
2951 for stack in roots {
2952 push_inventory_rows_filtered(
2953 &mut rows,
2954 0,
2955 stack,
2956 &flatland_protocol::InventoryLocation::Root,
2957 None,
2958 InventorySection::Person,
2959 filter,
2960 );
2961 }
2962 rows
2963 }
2964
2965 pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2966 if filter.is_empty() {
2967 return self.worn_rows();
2968 }
2969 let mut rows = Vec::new();
2970 for (slot, item) in &self.worn {
2971 if !stack_matches_filter(item, filter) {
2972 continue;
2973 }
2974 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2975 let self_hit = {
2976 let f = filter.to_ascii_lowercase();
2977 let name = item
2978 .display_name
2979 .as_deref()
2980 .unwrap_or("")
2981 .to_ascii_lowercase();
2982 let tid = item.template_id.to_ascii_lowercase();
2983 name.contains(&f) || tid.contains(&f)
2984 };
2985 rows.push(InventoryRow {
2986 depth: 0,
2987 stack: item.clone(),
2988 from: from.clone(),
2989 from_parent_instance_id: None,
2990 is_equip_shell: true,
2991 is_chest_shell: false,
2992 section: InventorySection::Worn,
2993 });
2994 for child in &item.contents {
2995 if self_hit || stack_matches_filter(child, filter) {
2996 push_inventory_rows_filtered(
2997 &mut rows,
2998 1,
2999 child,
3000 &from,
3001 item.item_instance_id,
3002 InventorySection::Worn,
3003 if self_hit { "" } else { filter },
3004 );
3005 }
3006 }
3007 }
3008 rows
3009 }
3010
3011 pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
3013 let mut rows = self.worn_rows();
3014 rows.extend(self.person_rows());
3015 rows.into_iter().map(|r| (r.depth, r.stack)).collect()
3016 }
3017
3018 pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
3022 let (px, py) = self.player_position();
3023 let mut list: Vec<NearbyContainer> = self
3024 .placed_containers
3025 .iter()
3026 .filter_map(|c| {
3027 let distance_m = (c.x - px).hypot(c.y - py);
3028 if distance_m > CONTAINER_RANGE_M {
3029 return None;
3030 }
3031 let mut rows = Vec::new();
3032 let from = flatland_protocol::InventoryLocation::Placed {
3033 container_id: c.id.clone(),
3034 };
3035 rows.push(InventoryRow {
3036 depth: 0,
3037 stack: flatland_protocol::ItemStack {
3038 template_id: c.template_id.clone(),
3039 quantity: 1,
3040 item_instance_id: c.item_instance_id,
3041 props: Default::default(),
3042 status_bindings: Vec::new(),
3043 contents: Vec::new(),
3044 display_name: Some(c.display_name.clone()),
3045 category: Some("container".into()),
3046 capacity_volume: c.capacity_volume,
3047 worker_lodging_capacity: c.worker_lodging_capacity,
3048 ..Default::default()
3049 },
3050 from: from.clone(),
3051 from_parent_instance_id: None,
3052 is_equip_shell: false,
3053 is_chest_shell: true,
3054 section: InventorySection::Nearby,
3055 });
3056 if c.accessible {
3057 for child in &c.contents {
3058 push_inventory_rows(
3059 &mut rows,
3060 1,
3061 child,
3062 &from,
3063 c.item_instance_id,
3064 InventorySection::Nearby,
3065 );
3066 }
3067 }
3068 Some(NearbyContainer {
3069 view: c.clone(),
3070 distance_m,
3071 rows,
3072 })
3073 })
3074 .collect();
3075 list.sort_by(|a, b| {
3076 a.distance_m
3077 .partial_cmp(&b.distance_m)
3078 .unwrap_or(std::cmp::Ordering::Equal)
3079 });
3080 list
3081 }
3082
3083 pub fn nearest_placed_container(
3085 &self,
3086 max_dist: f32,
3087 ) -> Option<flatland_protocol::PlacedContainerView> {
3088 let (px, py) = self.player_position();
3089 self.placed_containers
3090 .iter()
3091 .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
3092 .min_by(|a, b| {
3093 let da = (a.x - px).hypot(a.y - py);
3094 let db = (b.x - px).hypot(b.y - py);
3095 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3096 })
3097 .cloned()
3098 }
3099
3100 pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
3103 let filter = self.inventory_filter.as_str();
3104 match self.inventory_tab {
3105 InventoryTab::OnPerson => {
3106 let mut rows = self.worn_rows_filtered(filter);
3107 rows.extend(self.person_rows_filtered(filter));
3108 rows
3109 }
3110 InventoryTab::Nearby => {
3111 let mut rows = Vec::new();
3112 for nc in self.nearby_containers() {
3113 if filter.is_empty() {
3114 rows.extend(nc.rows);
3115 continue;
3116 }
3117 let shell = nc.rows.first().cloned();
3118 let contents: Vec<_> = nc
3119 .rows
3120 .iter()
3121 .skip(1)
3122 .filter(|r| stack_matches_filter(&r.stack, filter))
3123 .cloned()
3124 .collect();
3125 let shell_hit = shell
3126 .as_ref()
3127 .map(|s| stack_matches_filter(&s.stack, filter))
3128 .unwrap_or(false);
3129 if shell_hit || !contents.is_empty() {
3130 if let Some(s) = shell {
3131 rows.push(s);
3132 }
3133 if shell_hit {
3134 rows.extend(nc.rows.into_iter().skip(1));
3135 } else {
3136 rows.extend(contents);
3137 }
3138 }
3139 }
3140 rows
3141 }
3142 }
3143 }
3144
3145 pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
3146 self.inventory_selectable_rows()
3147 .into_iter()
3148 .nth(self.inventory_menu_index)
3149 }
3150
3151 fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
3152 let cat = self
3153 .inventory_item_category(&row.stack.template_id)
3154 .unwrap_or("");
3155 if cat == "key" {
3156 self.key_inventory_label(&row.stack)
3157 } else {
3158 row.stack
3159 .display_name
3160 .clone()
3161 .unwrap_or_else(|| row.stack.template_id.clone())
3162 }
3163 }
3164
3165 fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
3167 let bindings = format_status_bindings_suffix(
3168 &row.stack.status_bindings,
3169 self.tick,
3170 DEFAULT_TICK_HZ,
3171 );
3172 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3173 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3174 let mode = Self::grant_mode(&row.stack);
3175 format!(" [grant {effect} · {mode} — e apply]")
3176 } else {
3177 String::new()
3178 };
3179 let qty = if row.stack.quantity > 1 {
3180 format!(" ×{}", row.stack.quantity)
3181 } else {
3182 String::new()
3183 };
3184 let worn_slot = if row.is_equip_shell {
3185 match row.from {
3186 flatland_protocol::InventoryLocation::Worn { slot } => {
3187 format!(" ({})", body_slot_label(slot))
3188 }
3189 _ => String::new(),
3190 }
3191 } else {
3192 String::new()
3193 };
3194 format!("{grant_hint}{bindings}{qty}{worn_slot}")
3195 }
3196
3197 fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
3198 (
3199 row.stack.template_id.clone(),
3200 self.inventory_row_base_label(row),
3201 self.inventory_row_visible_mod_signature(row),
3202 )
3203 }
3204
3205 fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
3207 let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
3208 for row in self.inventory_selectable_rows() {
3209 if row.stack.item_instance_id.is_none() {
3210 continue;
3211 }
3212 let key = self.inventory_row_instance_identity_key(&row);
3213 *counts.entry(key).or_default() += 1;
3214 }
3215 counts
3216 .into_iter()
3217 .filter(|(_, n)| *n > 1)
3218 .map(|(k, _)| k)
3219 .collect()
3220 }
3221
3222 fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
3223 let hex: String = id
3224 .as_simple()
3225 .to_string()
3226 .chars()
3227 .filter(|c| c.is_ascii_hexdigit())
3228 .collect();
3229 let short = if hex.len() >= 4 {
3230 &hex[hex.len() - 4..]
3231 } else {
3232 hex.as_str()
3233 };
3234 format!("Instance {id} (#{short})")
3235 }
3236
3237 pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
3239 let cat = self
3240 .inventory_item_category(&row.stack.template_id)
3241 .unwrap_or("");
3242 let label = self.inventory_row_base_label(row);
3243 let hint: String = if row.is_equip_shell {
3244 " [worn — Enter to unequip]".into()
3245 } else if row.is_chest_shell {
3246 let (locked, lodging_note) = match &row.from {
3247 flatland_protocol::InventoryLocation::Placed { container_id } => {
3248 let locked = self
3249 .placed_containers
3250 .iter()
3251 .find(|c| c.id == *container_id)
3252 .map(|c| c.locked)
3253 .unwrap_or(false);
3254 let lodging_note = self
3255 .lodging_occupancy_label(container_id)
3256 .map(|who| format!(" [lodging: {who}]"))
3257 .unwrap_or_default();
3258 (locked, lodging_note)
3259 }
3260 _ => (false, String::new()),
3261 };
3262 if locked {
3263 format!(" [locked — Enter pick up · l unlock]{lodging_note}")
3264 } else {
3265 format!(" [Enter pick up · l lock]{lodging_note}")
3266 }
3267 } else if cat == "key" {
3268 self.key_inventory_hint(&row.stack)
3269 } else {
3270 match cat {
3271 "weapon" => " [weapon]".into(),
3272 "container" => " [bag/chest/belt]".into(),
3273 "lodging" => " [worker lodging]".into(),
3274 "armor" => " [armor]".into(),
3275 _ => String::new(),
3276 }
3277 };
3278 let qty = if row.stack.quantity > 1 {
3279 format!(" ×{}", row.stack.quantity)
3280 } else {
3281 String::new()
3282 };
3283 let bindings = format_status_bindings_suffix(
3284 &row.stack.status_bindings,
3285 self.tick,
3286 DEFAULT_TICK_HZ,
3287 );
3288 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3289 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3290 let mode = Self::grant_mode(&row.stack);
3291 format!(" [grant {effect} · {mode} — e apply]")
3292 } else {
3293 String::new()
3294 };
3295 let mass = self.stack_mass(&row.stack);
3296 let mass_kg = (mass >= 0.05).then_some(mass);
3297 let mass_str = mass_kg
3298 .map(|m| format!(" {m:.1} kg"))
3299 .unwrap_or_default();
3300 let volume = self.container_volume_stats(row);
3301 let vol_str = self.container_volume_label(row);
3302
3303 let mut title = label.clone();
3304 title.push_str(&qty);
3305 if row.is_equip_shell {
3306 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3307 title.push_str(&format!(" ({})", body_slot_label(slot)));
3308 }
3309 }
3310
3311 InventoryRowView {
3312 depth: row.depth,
3313 text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
3314 title: format!("{title}{grant_hint}{bindings}"),
3315 mass_kg,
3316 volume,
3317 instance_tooltip: None,
3318 }
3319 }
3320
3321 fn push_browser_item(
3322 &self,
3323 lines: &mut Vec<InventoryBrowserLine>,
3324 row: &InventoryRow,
3325 global_idx: &mut usize,
3326 target: usize,
3327 highlight: bool,
3328 ambiguous_instance_keys: &HashSet<(String, String, String)>,
3329 ) {
3330 let mut view = self.format_inventory_row(row);
3331 if let Some(id) = row.stack.item_instance_id {
3332 let key = self.inventory_row_instance_identity_key(row);
3333 if ambiguous_instance_keys.contains(&key) {
3334 view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
3335 }
3336 }
3337 lines.push(InventoryBrowserLine::Item {
3338 selectable_index: *global_idx,
3339 selected: highlight && *global_idx == target,
3340 depth: view.depth,
3341 text: view.text,
3342 title: view.title,
3343 mass_kg: view.mass_kg,
3344 volume: view.volume,
3345 instance_tooltip: view.instance_tooltip,
3346 });
3347 *global_idx += 1;
3348 }
3349
3350 pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
3353 let mut lines = Vec::new();
3354 let target = self.inventory_menu_index;
3355 let highlight = !self.show_move_picker && !self.show_grant_picker;
3356 let filter = self.inventory_filter.as_str();
3357 let mut global_idx = 0usize;
3358 let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
3359
3360 match self.inventory_tab {
3361 InventoryTab::OnPerson => {
3362 lines.push(InventoryBrowserLine::Section("— Worn —".into()));
3363 let worn = self.worn_rows_filtered(filter);
3364 if worn.is_empty() {
3365 lines.push(InventoryBrowserLine::Hint(
3366 " (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
3367 ));
3368 } else {
3369 for row in &worn {
3370 if row.is_equip_shell {
3371 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3372 lines.push(InventoryBrowserLine::SlotLabel(format!(
3373 " {}:",
3374 body_slot_label(slot)
3375 )));
3376 }
3377 }
3378 self.push_browser_item(
3379 &mut lines,
3380 row,
3381 &mut global_idx,
3382 target,
3383 highlight,
3384 &ambiguous_instance_keys,
3385 );
3386 }
3387 }
3388
3389 lines.push(InventoryBrowserLine::Blank);
3390 lines.push(InventoryBrowserLine::Section(
3391 "— On you (loose, not worn) —".into(),
3392 ));
3393 let person = self.person_rows_filtered(filter);
3394 if person.is_empty() {
3395 lines.push(InventoryBrowserLine::Hint(" (empty)".into()));
3396 } else {
3397 let mut last_group: Option<&'static str> = None;
3398 for row in &person {
3399 if row.depth == 0 {
3400 let cat = row
3401 .stack
3402 .category
3403 .as_deref()
3404 .or_else(|| self.inventory_item_category(&row.stack.template_id))
3405 .unwrap_or("");
3406 let (group, _) = inventory_category_group(cat);
3407 if last_group != Some(group) {
3408 lines.push(InventoryBrowserLine::SlotLabel(format!(
3409 " {group}"
3410 )));
3411 last_group = Some(group);
3412 }
3413 }
3414 self.push_browser_item(
3415 &mut lines,
3416 row,
3417 &mut global_idx,
3418 target,
3419 highlight,
3420 &ambiguous_instance_keys,
3421 );
3422 }
3423 }
3424 }
3425 InventoryTab::Nearby => {
3426 let nearby = self.nearby_containers();
3427 if nearby.is_empty() {
3428 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3429 lines.push(InventoryBrowserLine::Hint(
3430 " (none within reach — walk up to a chest)".into(),
3431 ));
3432 lines.push(InventoryBrowserLine::Hint(
3433 " Select an on-person item, then m / Enter → move into chest.".into(),
3434 ));
3435 } else {
3436 let mut any_visible = false;
3437 for nc in &nearby {
3438 let shell = nc.rows.first();
3439 let contents: Vec<&InventoryRow> = if filter.is_empty() {
3440 nc.rows.iter().skip(1).collect()
3441 } else {
3442 let shell_hit = shell
3443 .map(|s| {
3444 let f = filter.to_ascii_lowercase();
3445 let name = s
3446 .stack
3447 .display_name
3448 .as_deref()
3449 .unwrap_or("")
3450 .to_ascii_lowercase();
3451 let tid = s.stack.template_id.to_ascii_lowercase();
3452 name.contains(&f) || tid.contains(&f)
3453 })
3454 .unwrap_or(false);
3455 if shell_hit {
3456 nc.rows.iter().skip(1).collect()
3457 } else {
3458 nc.rows
3459 .iter()
3460 .skip(1)
3461 .filter(|r| stack_matches_filter(&r.stack, filter))
3462 .collect()
3463 }
3464 };
3465 let shell_visible = filter.is_empty()
3466 || shell
3467 .map(|s| stack_matches_filter(&s.stack, filter))
3468 .unwrap_or(false)
3469 || !contents.is_empty();
3470 if !shell_visible && shell.is_some() {
3471 continue;
3472 }
3473 any_visible = true;
3474 lines.push(InventoryBrowserLine::Blank);
3475 let lock_note = if nc.view.locked && nc.view.accessible {
3476 " unlocked with your key"
3477 } else if nc.view.locked {
3478 " locked"
3479 } else {
3480 ""
3481 };
3482 lines.push(InventoryBrowserLine::Section(format!(
3483 "— {} ({:.0}m away){lock_note} —",
3484 nc.view.display_name, nc.distance_m
3485 )));
3486 if !nc.view.accessible {
3487 lines.push(InventoryBrowserLine::Hint(
3488 " locked — need the matching key (l to try)".into(),
3489 ));
3490 } else if nc.rows.is_empty() {
3491 lines.push(InventoryBrowserLine::Hint(
3492 " (empty — switch to On person, select an item, m to move in)"
3493 .into(),
3494 ));
3495 } else if let Some(shell_row) = shell {
3496 self.push_browser_item(
3497 &mut lines,
3498 shell_row,
3499 &mut global_idx,
3500 target,
3501 highlight,
3502 &ambiguous_instance_keys,
3503 );
3504 for row in contents {
3505 self.push_browser_item(
3506 &mut lines,
3507 row,
3508 &mut global_idx,
3509 target,
3510 highlight,
3511 &ambiguous_instance_keys,
3512 );
3513 }
3514 }
3515 }
3516 if !any_visible {
3517 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3518 lines.push(InventoryBrowserLine::Hint(
3519 " (no matching items — clear filter with Esc)".into(),
3520 ));
3521 }
3522 }
3523 }
3524 }
3525 lines
3526 }
3527
3528 pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
3530 let mut opts = Vec::new();
3531 opts.push(MoveOption {
3532 label: "Relocate…".into(),
3533 kind: MoveOptionKind::RelocatePlaced {
3534 container_id: container_id.to_string(),
3535 },
3536 });
3537 opts.push(MoveOption {
3538 label: "On your person (loose)".into(),
3539 kind: MoveOptionKind::PickupPlaced {
3540 container_id: container_id.to_string(),
3541 nest_location: flatland_protocol::InventoryLocation::Root,
3542 nest_parent_instance_id: None,
3543 },
3544 });
3545 for (slot, item) in &self.worn {
3546 if item.category.as_deref() != Some("container") {
3547 continue;
3548 }
3549 if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
3550 continue;
3551 }
3552 let Some(parent_id) = item.item_instance_id else {
3553 continue;
3554 };
3555 let shell_name = item
3556 .display_name
3557 .clone()
3558 .unwrap_or_else(|| item.template_id.clone());
3559 opts.push(MoveOption {
3560 label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
3561 kind: MoveOptionKind::PickupPlaced {
3562 container_id: container_id.to_string(),
3563 nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
3564 nest_parent_instance_id: Some(parent_id),
3565 },
3566 });
3567 Self::append_chest_pickup_nested(
3569 &mut opts,
3570 container_id,
3571 flatland_protocol::InventoryLocation::Worn { slot: *slot },
3572 item,
3573 &format!("in {shell_name}"),
3574 );
3575 }
3576 opts.push(MoveOption {
3577 label: "Cancel".into(),
3578 kind: MoveOptionKind::Cancel,
3579 });
3580 opts
3581 }
3582
3583 fn append_chest_pickup_nested(
3584 opts: &mut Vec<MoveOption>,
3585 container_id: &str,
3586 location: flatland_protocol::InventoryLocation,
3587 parent: &flatland_protocol::ItemStack,
3588 context: &str,
3589 ) {
3590 for child in &parent.contents {
3591 if child.category.as_deref() != Some("container") {
3592 continue;
3593 }
3594 if !Self::is_volume_container_stack(child) {
3595 continue;
3596 }
3597 if child.world_placeable == Some(true) {
3599 continue;
3600 }
3601 let Some(child_id) = child.item_instance_id else {
3602 continue;
3603 };
3604 let name = child
3605 .display_name
3606 .clone()
3607 .unwrap_or_else(|| child.template_id.clone());
3608 opts.push(MoveOption {
3609 label: format!("{name} ({context})"),
3610 kind: MoveOptionKind::PickupPlaced {
3611 container_id: container_id.to_string(),
3612 nest_location: location.clone(),
3613 nest_parent_instance_id: Some(child_id),
3614 },
3615 });
3616 Self::append_chest_pickup_nested(
3617 opts,
3618 container_id,
3619 location.clone(),
3620 child,
3621 &format!("in {name}"),
3622 );
3623 }
3624 }
3625
3626 pub fn move_destinations_for(
3628 &self,
3629 from: &flatland_protocol::InventoryLocation,
3630 from_parent_instance_id: Option<uuid::Uuid>,
3631 moving_instance_id: Option<uuid::Uuid>,
3632 moving_template_id: &str,
3633 ) -> Vec<MoveOption> {
3634 let mut opts = Vec::new();
3635 if *from != flatland_protocol::InventoryLocation::Root {
3636 opts.push(MoveOption {
3637 label: "On your person (loose)".into(),
3638 kind: MoveOptionKind::Move {
3639 location: flatland_protocol::InventoryLocation::Root,
3640 parent_instance_id: None,
3641 },
3642 });
3643 }
3644 for (slot, item) in &self.worn {
3645 if item.category.as_deref() != Some("container") {
3646 continue;
3647 }
3648 let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3649 let shell_name = item
3650 .display_name
3651 .clone()
3652 .unwrap_or_else(|| item.template_id.clone());
3653
3654 if *slot != BodySlot::Waist
3656 && item.item_instance_id != moving_instance_id
3657 && Self::is_volume_container_stack(item)
3658 {
3659 Self::push_move_destination(
3660 &mut opts,
3661 format!("{shell_name} (worn {})", body_slot_label(*slot)),
3662 location.clone(),
3663 item.item_instance_id,
3664 from,
3665 from_parent_instance_id,
3666 );
3667 }
3668
3669 if *slot == BodySlot::Waist
3671 && Self::attaches_to_belt_loop(moving_template_id)
3672 && item.item_instance_id != moving_instance_id
3673 {
3674 Self::push_move_destination(
3675 &mut opts,
3676 format!("{shell_name} (belt loop)"),
3677 location.clone(),
3678 item.item_instance_id,
3679 from,
3680 from_parent_instance_id,
3681 );
3682 }
3683
3684 let context = if *slot == BodySlot::Waist {
3685 format!("on {shell_name}")
3686 } else {
3687 format!("in {shell_name}")
3688 };
3689 Self::append_nested_container_destinations(
3690 &mut opts,
3691 location,
3692 item,
3693 &context,
3694 from,
3695 from_parent_instance_id,
3696 moving_instance_id,
3697 );
3698 }
3699 for nc in self.nearby_containers() {
3700 if !nc.view.accessible {
3701 continue;
3702 }
3703 let location = flatland_protocol::InventoryLocation::Placed {
3704 container_id: nc.view.id.clone(),
3705 };
3706 Self::push_move_destination(
3707 &mut opts,
3708 format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
3709 location,
3710 nc.view.item_instance_id,
3711 from,
3712 from_parent_instance_id,
3713 );
3714 }
3715 let allow_drop = moving_instance_id
3716 .and_then(|id| self.stack_for_instance(id))
3717 .map(|stack| {
3718 !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
3719 })
3720 .unwrap_or(
3721 moving_template_id != KEY_TEMPLATE && moving_template_id != PROPERTY_DEED_TEMPLATE,
3722 );
3723 if allow_drop {
3724 opts.push(MoveOption {
3725 label: "Drop on the ground".into(),
3726 kind: MoveOptionKind::Drop,
3727 });
3728 }
3729 opts.push(MoveOption {
3730 label: "Cancel".into(),
3731 kind: MoveOptionKind::Cancel,
3732 });
3733 opts
3734 }
3735
3736 fn is_same_container_dest(
3737 dest_location: &flatland_protocol::InventoryLocation,
3738 dest_parent: Option<uuid::Uuid>,
3739 from: &flatland_protocol::InventoryLocation,
3740 from_parent: Option<uuid::Uuid>,
3741 ) -> bool {
3742 dest_location == from && dest_parent == from_parent
3743 }
3744
3745 fn push_move_destination(
3746 opts: &mut Vec<MoveOption>,
3747 label: String,
3748 location: flatland_protocol::InventoryLocation,
3749 parent_instance_id: Option<uuid::Uuid>,
3750 from: &flatland_protocol::InventoryLocation,
3751 from_parent_instance_id: Option<uuid::Uuid>,
3752 ) {
3753 if Self::is_same_container_dest(
3754 &location,
3755 parent_instance_id,
3756 from,
3757 from_parent_instance_id,
3758 ) {
3759 return;
3760 }
3761 opts.push(MoveOption {
3762 label,
3763 kind: MoveOptionKind::Move {
3764 location,
3765 parent_instance_id,
3766 },
3767 });
3768 }
3769
3770 fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
3771 stack.capacity_volume.is_some_and(|c| c > 0.0)
3772 }
3773
3774 fn attaches_to_belt_loop(template_id: &str) -> bool {
3775 matches!(template_id, "leather_pouch" | "dimensional_pouch")
3776 }
3777
3778 fn append_nested_container_destinations(
3779 opts: &mut Vec<MoveOption>,
3780 location: flatland_protocol::InventoryLocation,
3781 container: &flatland_protocol::ItemStack,
3782 context: &str,
3783 from: &flatland_protocol::InventoryLocation,
3784 from_parent_instance_id: Option<uuid::Uuid>,
3785 moving_instance_id: Option<uuid::Uuid>,
3786 ) {
3787 for child in &container.contents {
3788 if Self::is_volume_container_stack(child)
3789 && child.item_instance_id != moving_instance_id
3790 {
3791 let name = child
3792 .display_name
3793 .clone()
3794 .unwrap_or_else(|| child.template_id.clone());
3795 Self::push_move_destination(
3796 opts,
3797 format!("{name} ({context})"),
3798 location.clone(),
3799 child.item_instance_id,
3800 from,
3801 from_parent_instance_id,
3802 );
3803 }
3804 let nested_context = format!(
3805 "in {}",
3806 child.display_name.as_deref().unwrap_or(&child.template_id)
3807 );
3808 Self::append_nested_container_destinations(
3809 opts,
3810 location.clone(),
3811 child,
3812 &nested_context,
3813 from,
3814 from_parent_instance_id,
3815 moving_instance_id,
3816 );
3817 }
3818 }
3819
3820 fn clamp_inventory_indices(&mut self) {
3821 let n = self.inventory_selectable_rows().len();
3822 self.inventory_menu_index = if n == 0 {
3823 0
3824 } else {
3825 self.inventory_menu_index.min(n - 1)
3826 };
3827 if let Some(picker) = &self.move_picker {
3828 let pn = picker.options.len();
3829 self.move_picker_index = if pn == 0 {
3830 0
3831 } else {
3832 self.move_picker_index.min(pn - 1)
3833 };
3834 }
3835 }
3836
3837 fn sync_interior_map_context(&mut self) {
3842 if self.effective_inside_building().is_none() {
3843 self.interior_map = None;
3844 if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
3845 self.z_platforms = platforms;
3846 self.z_transitions = transitions;
3847 }
3848 return;
3849 }
3850 self.sync_interior_z_bands();
3851 }
3852
3853 fn sync_interior_z_bands(&mut self) {
3855 if self.effective_inside_building().is_some() {
3856 if let Some(map) = &self.interior_map {
3857 if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
3858 if self.z_bands_outdoor_backup.is_none() {
3859 self.z_bands_outdoor_backup = Some((
3860 std::mem::take(&mut self.z_platforms),
3861 std::mem::take(&mut self.z_transitions),
3862 ));
3863 }
3864 self.z_platforms = map.z_platforms.clone();
3865 self.z_transitions = map.z_transitions.clone();
3866 }
3867 }
3868 }
3869 }
3870
3871 fn apply_snapshot_fields(
3872 &mut self,
3873 snapshot: &flatland_protocol::Snapshot,
3874 entity_id: EntityId,
3875 ) {
3876 self.tick = snapshot.tick;
3877 self.chunk_rev = snapshot.chunk_rev;
3878 self.content_rev = snapshot.content_rev;
3879 self.publish_rev = snapshot.publish_rev;
3880 self.resource_nodes = snapshot.resource_nodes.clone();
3881 self.ground_drops = snapshot.ground_drops.clone();
3882 self.placed_containers = snapshot.placed_containers.clone();
3883 self.world_x0 = snapshot.world_x0;
3884 self.world_y0 = snapshot.world_y0;
3885 self.world_width_m = snapshot.world_width_m;
3886 self.world_height_m = snapshot.world_height_m;
3887 self.world_clock = snapshot.world_clock;
3888 self.terrain_zones = snapshot.terrain_zones.clone();
3889 self.z_platforms = snapshot.z_platforms.clone();
3890 self.z_transitions = snapshot.z_transitions.clone();
3891 self.z_bands_outdoor_backup = None;
3893 self.buildings = snapshot.buildings.clone();
3894 self.doors = snapshot.doors.clone();
3895 self.interior_map = snapshot.interior_map.clone();
3896 self.npcs = snapshot.npcs.clone();
3897 self.blueprints = snapshot.blueprints.clone();
3898 self.sync_inventory_from_stacks(&snapshot.inventory);
3899 self.player = snapshot
3900 .entities
3901 .iter()
3902 .find(|e| e.id == entity_id)
3903 .cloned();
3904 self.entities = snapshot.entities.clone();
3905 self.quest_log = snapshot.quest_log.clone();
3906 self.apply_hired_workers(snapshot.hired_workers.clone());
3907 self.interactables = snapshot.interactables.clone();
3908 self.ledger = snapshot.ledger.clone();
3909 self.career = snapshot.career.clone();
3910 self.combat_fx = snapshot.combat_fx.clone();
3911 self.property_zones = snapshot.property_zones.clone();
3912 self.tax_zones = snapshot.tax_zones.clone();
3913 self.growth_zones = snapshot.growth_zones.clone();
3914 self.biome_zones = snapshot.biome_zones.clone();
3915 self.property_plots = snapshot.property_plots.clone();
3916 self.property_plot_settings = snapshot.property_plot_settings.clone();
3917 if self.effective_inside_building().is_some() {
3920 self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
3921 }
3922 self.sync_interior_map_context();
3923 self.refresh_whisper_range();
3924 }
3925
3926 fn refresh_inventory_ui(&mut self) {
3930 if let Some(picker) = &self.move_picker {
3931 let instance_id = picker.item_instance_id;
3932 let still_exists = self
3933 .inventory_selectable_rows()
3934 .iter()
3935 .any(|r| r.stack.item_instance_id == Some(instance_id));
3936 if !still_exists {
3937 self.move_picker = None;
3938 self.show_move_picker = false;
3939 }
3940 }
3941 if let Some(picker) = &self.destroy_picker {
3942 let instance_id = picker.item_instance_id;
3943 let still_exists = self
3944 .inventory_selectable_rows()
3945 .iter()
3946 .any(|r| r.stack.item_instance_id == Some(instance_id));
3947 if !still_exists {
3948 self.destroy_picker = None;
3949 self.show_destroy_picker = false;
3950 self.destroy_confirm_pending = false;
3951 }
3952 }
3953 self.clamp_inventory_indices();
3954 }
3955
3956 fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
3962 let selected_id = self
3963 .hired_workers
3964 .get(self.workers_menu_index)
3965 .map(|w| w.instance_id.clone());
3966 workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
3967 let now = Instant::now();
3968 for w in &workers {
3969 let prev_err = self
3970 .hired_workers
3971 .iter()
3972 .find(|p| p.instance_id == w.instance_id)
3973 .and_then(|p| p.last_error.as_deref());
3974 let new_err = w.last_error.as_deref();
3975 if new_err != prev_err {
3976 if let Some(err) = new_err {
3977 if !worker_error_is_transient(err) {
3978 self.push_log(format!("Worker {}: {err}", w.label));
3979 }
3980 }
3981 }
3982 }
3983 let mut next_display = BTreeMap::new();
3984 let mut next_errors = BTreeMap::new();
3985 for w in &workers {
3986 let mut sticky = self
3987 .worker_step_display
3988 .remove(&w.instance_id)
3989 .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
3990 sticky.observe(&w.step_label, now);
3991 next_display.insert(w.instance_id.clone(), sticky);
3992
3993 let mut err_sticky = self
3994 .worker_error_display
3995 .remove(&w.instance_id)
3996 .unwrap_or_default();
3997 err_sticky.observe(w.last_error.as_deref(), now);
3998 if err_sticky.shown(now).is_some() {
3999 next_errors.insert(w.instance_id.clone(), err_sticky);
4000 }
4001 }
4002 self.worker_step_display = next_display;
4003 self.worker_error_display = next_errors;
4004 self.hired_workers = workers;
4005 self.sync_worker_take_picker_from_hired();
4006 if let Some(id) = selected_id {
4007 if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
4008 self.workers_menu_index = idx;
4009 return;
4010 }
4011 }
4012 if self.workers_menu_index >= self.hired_workers.len() {
4013 self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
4014 }
4015 }
4016
4017 fn sync_worker_take_picker_from_hired(&mut self) {
4019 if !self.show_worker_take_picker {
4020 return;
4021 }
4022 let Some(picker) = self.worker_take_picker.clone() else {
4023 return;
4024 };
4025 let Some(worker) = self
4026 .hired_workers
4027 .iter()
4028 .find(|w| w.instance_id == picker.worker_instance_id)
4029 .cloned()
4030 else {
4031 self.show_worker_take_picker = false;
4032 self.worker_take_picker = None;
4033 self.worker_take_picker_index = 0;
4034 return;
4035 };
4036 let options: Vec<WorkerGiveOption> = worker
4037 .inventory
4038 .iter()
4039 .filter_map(|stack| {
4040 let item_instance_id = stack.item_instance_id?;
4041 let label = stack
4042 .display_name
4043 .clone()
4044 .unwrap_or_else(|| stack.template_id.clone());
4045 let label = if stack.quantity > 1 {
4046 format!("{label} ×{}", stack.quantity)
4047 } else {
4048 label
4049 };
4050 Some(WorkerGiveOption {
4051 item_instance_id,
4052 label,
4053 quantity: stack.quantity,
4054 template_id: stack.template_id.clone(),
4055 })
4056 })
4057 .collect();
4058 if options.is_empty() {
4059 self.show_worker_take_picker = false;
4060 self.worker_take_picker = None;
4061 self.worker_take_picker_index = 0;
4062 return;
4063 }
4064 let prev_id = picker
4065 .options
4066 .get(self.worker_take_picker_index)
4067 .map(|o| o.item_instance_id);
4068 let idx = prev_id
4069 .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
4070 .unwrap_or(0)
4071 .min(options.len().saturating_sub(1));
4072 let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
4073 let quantity = picker.quantity.clamp(1, max_qty);
4074 self.worker_take_picker_index = idx;
4075 self.worker_take_picker = Some(WorkerTakePicker {
4076 worker_instance_id: picker.worker_instance_id,
4077 worker_label: picker.worker_label,
4078 options,
4079 quantity,
4080 });
4081 }
4082
4083 pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
4085 self.worker_step_display
4086 .get(worker_instance_id)
4087 .map(|s| s.shown.as_str())
4088 .or_else(|| {
4089 self.hired_workers
4090 .iter()
4091 .find(|w| w.instance_id == worker_instance_id)
4092 .map(|w| w.step_label.as_str())
4093 })
4094 .unwrap_or("")
4095 }
4096
4097 pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
4099 let now = Instant::now();
4100 self.worker_error_display
4101 .get(worker_instance_id)
4102 .and_then(|s| s.shown(now))
4103 .or_else(|| {
4104 self.hired_workers
4105 .iter()
4106 .find(|w| w.instance_id == worker_instance_id)
4107 .and_then(|w| w.last_error.as_deref())
4108 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
4109 })
4110 .filter(|e| !worker_error_is_hud_noise(e))
4111 }
4112
4113 fn apply_combat_hud(&mut self, combat: &CombatHud) {
4114 self.in_combat = combat.in_combat;
4115 self.auto_attack = combat.auto_attack;
4116 self.combat_has_los = combat.has_los;
4117 self.attack_cd_ticks = combat.attack_cd_ticks;
4118 self.gcd_ticks = combat.gcd_ticks;
4119 self.weapon_ability_id = combat.ability_id.clone();
4120 self.mainhand_template_id = combat.mainhand_template_id.clone();
4121 self.mainhand_label = combat.mainhand_label.clone();
4122 self.offhand_template_id = combat.offhand_template_id.clone();
4123 self.offhand_label = combat.offhand_label.clone();
4124 self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
4125 1
4126 } else {
4127 combat.mainhand_hand_slots
4128 };
4129 self.defense = combat.defense.clone();
4130 self.worn = combat.worn.iter().cloned().collect();
4131 self.carry_mass = combat.carry_mass;
4132 self.carry_mass_max = combat.carry_mass_max;
4133 self.encumbrance = combat.encumbrance;
4134 self.cast_progress = combat.cast.clone();
4135 self.timed_channel = combat.timed_channel.clone();
4136 self.ability_cooldowns = combat.ability_cooldowns.clone();
4137 self.blocking_active = combat.blocking_active;
4138 self.max_target_slots = combat.max_target_slots.max(1);
4139 self.combat_slots = combat.slots.clone();
4140 self.rotation_presets = combat.rotation_presets.clone();
4141 self.known_abilities = combat.known_abilities.clone();
4142 self.ability_meta = combat
4143 .ability_meta
4144 .iter()
4145 .cloned()
4146 .map(|meta| (meta.id.clone(), meta))
4147 .collect();
4148 self.hotbar = combat.hotbar.clone();
4149 self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
4150 self.keychain_stacks = combat.keychain.clone();
4151 self.whisper_pouch_stacks = combat.whisper_pouch.clone();
4152 self.combat_target_detail = combat.target.clone();
4153 self.statuses = combat.statuses.clone();
4154 self.combat_target = combat.target_entity_id;
4155 if combat.progression_xp_base > 0.0 {
4156 self.progression_curve = Some(flatland_protocol::ProgressionCurve {
4157 baseline_display: combat.progression_baseline,
4158 xp_base: combat.progression_xp_base,
4159 xp_growth: combat.progression_xp_growth,
4160 });
4161 }
4162 if let Some(xp) = &combat.progression_xp {
4163 if let Some(player) = &mut self.player {
4164 player.progression_xp = Some(xp.clone());
4165 if let Some(attrs) = combat.attributes {
4166 player.attributes = Some(attrs);
4167 }
4168 if let Some(skills) = &combat.skills {
4169 player.skills = Some(skills.clone());
4170 }
4171 }
4172 }
4173 if let Some(label) = &combat.target_label {
4174 self.combat_target_label = Some(label.clone());
4175 } else if let Some(id) = combat.target_entity_id {
4176 self.combat_target_label = self
4177 .entities
4178 .iter()
4179 .find(|e| e.id == id)
4180 .map(|e| e.label.clone())
4181 .or_else(|| self.combat_target_label.clone());
4182 }
4183 self.refresh_inventory_ui();
4184 }
4185
4186 pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
4188 self.combat_slots
4189 .iter()
4190 .find(|s| s.slot_index == slot)
4191 .and_then(|s| s.target_entity_id)
4192 .or_else(|| if slot == 1 { self.combat_target } else { None })
4193 }
4194
4195 pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
4197 self.ability_meta
4198 .get(ability_id)
4199 .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
4200 .unwrap_or(self.ground_target.is_some())
4203 }
4204
4205 pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
4207 self.ability_meta
4208 .get(ability_id)
4209 .map(|meta| meta.aim_mode == "ground")
4210 .unwrap_or(false)
4211 }
4212
4213 pub fn set_ground_target(&mut self, x: f32, y: f32) {
4215 self.ground_target = Some((x, y, 0.0));
4216 }
4217
4218 pub fn clear_ground_target(&mut self) {
4220 self.ground_target = None;
4221 }
4222
4223 pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
4226 if !(1..=9).contains(&slot_1_to_9) {
4227 return None;
4228 }
4229 self.hotbar
4230 .get((slot_1_to_9 - 1) as usize)
4231 .and_then(|a| a.as_deref())
4232 .filter(|id| !id.is_empty())
4233 }
4234
4235 pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
4237 let binding = self.hotbar_ability(slot_1_to_9)?;
4238 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
4239 let name = self
4240 .inventory_hints
4241 .get(template_id)
4242 .map(|h| h.display_name.as_str())
4243 .unwrap_or(template_id);
4244 let qty = self.inventory.get(template_id).copied().unwrap_or(0);
4245 Some(format!("{name}×{qty}"))
4246 } else {
4247 Some(binding.to_string())
4248 }
4249 }
4250
4251 pub fn loadout_ability_choices(&self) -> Vec<String> {
4253 let mut out = self.known_abilities.clone();
4254 let weapon = self.weapon_ability_id.trim();
4255 if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
4256 out.push(weapon.to_string());
4257 }
4258 out
4259 }
4260
4261 pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
4263 let mut out = Vec::new();
4264 for ability in self.loadout_ability_choices() {
4265 let meta = if ability == self.weapon_ability_id {
4266 Some("weapon".into())
4267 } else {
4268 None
4269 };
4270 out.push(LoadoutHotbarChoice {
4271 binding: ability.clone(),
4272 label: ability,
4273 meta,
4274 });
4275 }
4276 let mut consumables: Vec<(String, String, u32)> = Vec::new();
4277 for stack in &self.inventory_stacks {
4278 if Self::stack_is_item_grant(stack) {
4279 continue;
4280 }
4281 if self.inventory_item_category(&stack.template_id) != Some("consumable") {
4282 continue;
4283 }
4284 let qty = stack.quantity.max(1);
4285 if let Some((_, _, existing)) = consumables
4286 .iter_mut()
4287 .find(|(id, _, _)| id == &stack.template_id)
4288 {
4289 *existing = existing.saturating_add(qty);
4290 } else {
4291 let label = stack
4292 .display_name
4293 .clone()
4294 .or_else(|| {
4295 self.inventory_hints
4296 .get(&stack.template_id)
4297 .map(|h| h.display_name.clone())
4298 })
4299 .unwrap_or_else(|| stack.template_id.clone());
4300 consumables.push((stack.template_id.clone(), label, qty));
4301 }
4302 }
4303 consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
4304 for (template_id, label, qty) in consumables {
4305 out.push(LoadoutHotbarChoice {
4306 binding: flatland_protocol::hotbar_consumable_binding(&template_id),
4307 label: format!("{label} ×{qty}"),
4308 meta: Some("use".into()),
4309 });
4310 }
4311 out
4312 }
4313
4314 pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
4316 self.combat_candidates()
4317 }
4318
4319 pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
4321 let (px, py) = self.player_position();
4322 let dist = |id: EntityId| {
4323 self.entities
4324 .iter()
4325 .find(|e| e.id == id)
4326 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4327 .unwrap_or(f32::MAX)
4328 };
4329
4330 let mut allies = Vec::new();
4331 if let Some(me) = self.player.as_ref() {
4333 let alive = me
4334 .vitals
4335 .as_ref()
4336 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4337 .unwrap_or(true);
4338 if alive {
4339 allies.push((self.entity_id, "Yourself".into()));
4340 }
4341 }
4342 for entity in &self.entities {
4343 if entity.id == self.entity_id {
4344 continue;
4345 }
4346 if entity.vitals.is_some() {
4347 let alive = entity
4348 .vitals
4349 .as_ref()
4350 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4351 .unwrap_or(true);
4352 if alive {
4353 allies.push((entity.id, entity.label.clone()));
4354 }
4355 }
4356 }
4357 allies.sort_by(|(a, _), (b, _)| {
4358 if *a == self.entity_id {
4359 return std::cmp::Ordering::Less;
4360 }
4361 if *b == self.entity_id {
4362 return std::cmp::Ordering::Greater;
4363 }
4364 dist(*a)
4365 .partial_cmp(&dist(*b))
4366 .unwrap_or(std::cmp::Ordering::Equal)
4367 });
4368
4369 let mut monsters = self.combat_candidates();
4370 monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
4371 allies.into_iter().chain(monsters).collect()
4372 }
4373
4374 fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
4375 match slot_index {
4376 2 => self.t2_candidates(),
4377 _ => self.t1_candidates(),
4378 }
4379 }
4380
4381 pub fn pick_combat_target_at(
4383 &self,
4384 wx: f32,
4385 wy: f32,
4386 slot_index: u8,
4387 radius_m: f32,
4388 ) -> Option<(EntityId, String)> {
4389 let mut best: Option<(f32, EntityId, String)> = None;
4390 for (id, label) in self.candidates_for_slot(slot_index) {
4391 let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
4392 if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
4394 let d = distance(wx, wy, npc.x, npc.y);
4395 if d <= radius_m {
4396 best = match best {
4397 Some((bd, _, _)) if bd <= d => best,
4398 _ => Some((d, id, label)),
4399 };
4400 }
4401 }
4402 continue;
4403 };
4404 let d = distance(
4405 wx,
4406 wy,
4407 entity.transform.position.x,
4408 entity.transform.position.y,
4409 );
4410 if d <= radius_m {
4411 best = match best {
4412 Some((bd, _, _)) if bd <= d => best,
4413 _ => Some((d, id, label)),
4414 };
4415 }
4416 }
4417 best.map(|(_, id, label)| (id, label))
4418 }
4419
4420 pub(crate) fn restore_from_welcome(
4422 &mut self,
4423 session_id: SessionId,
4424 entity_id: EntityId,
4425 snapshot: &flatland_protocol::Snapshot,
4426 ) {
4427 self.clear_harvest_state();
4428 self.disconnect_reason = None;
4429 self.show_stats = false;
4430 self.show_craft_menu = false;
4431 self.show_shop_menu = false;
4432 self.shop_catalog = None;
4433 self.show_inventory_menu = false;
4434 self.session_id = session_id;
4435 self.entity_id = entity_id;
4436 self.connected = true;
4437 self.apply_snapshot_fields(snapshot, entity_id);
4438 if let Some(combat) = &snapshot.combat {
4439 self.apply_combat_hud(combat);
4440 let stacks = self.inventory_stacks.clone();
4441 self.sync_inventory_from_stacks(&stacks);
4442 }
4443 }
4444
4445 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
4446 self.tick = delta.tick;
4447 self.world_clock = delta.world_clock;
4448
4449 if delta.entities.is_empty() {
4451 self.ground_drops = delta.ground_drops.clone();
4452 self.combat_fx = delta.combat_fx.clone();
4453 self.property_plots = delta.property_plots.clone();
4454 self.apply_terrain_overlays(&delta.terrain_overlays);
4455 if let Some(combat) = &delta.combat {
4456 self.apply_combat_hud(combat);
4457 let stacks = self.inventory_stacks.clone();
4458 self.sync_inventory_from_stacks(&stacks);
4459 }
4460 self.refresh_whisper_range();
4462 return;
4463 }
4464 if !delta.buildings.is_empty() {
4465 self.buildings = delta.buildings.clone();
4466 }
4467 if !delta.blueprints.is_empty() {
4468 self.blueprints = delta.blueprints.clone();
4469 }
4470 self.sync_inventory_from_stacks(&delta.inventory);
4471
4472 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
4473 self.player = Some(updated.clone());
4474 }
4475 self.entities = delta.entities.clone();
4476 if self.player.is_none() {
4477 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
4478 }
4479
4480 self.sync_interior_map_context();
4481
4482 if !delta.resource_nodes.is_empty() {
4486 self.resource_nodes = delta.resource_nodes.clone();
4487 } else if delta.interior_map.is_some()
4488 || self.effective_inside_building().is_some()
4489 {
4490 self.resource_nodes = delta.resource_nodes.clone();
4491 }
4492 self.ground_drops = delta.ground_drops.clone();
4493 if self
4494 .player
4495 .as_ref()
4496 .is_none_or(|p| p.inside_building.is_none())
4497 {
4498 self.placed_containers = delta.placed_containers.clone();
4499 }
4500 if !delta.doors.is_empty() {
4501 self.doors = delta.doors.clone();
4502 }
4503 if self.effective_inside_building().is_some() {
4504 if let Some(map) = &delta.interior_map {
4505 self.interior_map = Some(map.clone());
4506 }
4507 } else {
4508 self.interior_map = None;
4509 }
4510 self.sync_interior_z_bands();
4511 self.npcs = delta.npcs.clone();
4513 if !delta.quest_log.is_empty() {
4514 self.quest_log = delta.quest_log.clone();
4515 }
4516 self.apply_hired_workers(delta.hired_workers.clone());
4517 if !delta.interactables.is_empty() {
4518 self.interactables = delta.interactables.clone();
4519 }
4520 if delta.ledger.is_some() {
4521 self.ledger = delta.ledger.clone();
4522 }
4523 if delta.career.is_some() {
4524 self.career = delta.career.clone();
4525 }
4526 self.combat_fx = delta.combat_fx.clone();
4527 if !delta.property_plots.is_empty() {
4529 self.property_plots = delta.property_plots.clone();
4530 }
4531 self.apply_terrain_overlays(&delta.terrain_overlays);
4532 if let Some(combat) = &delta.combat {
4533 self.apply_combat_hud(combat);
4534 let stacks = self.inventory_stacks.clone();
4535 self.sync_inventory_from_stacks(&stacks);
4536 } else {
4537 self.refresh_inventory_ui();
4538 }
4539 self.refresh_whisper_range();
4540 }
4541
4542 fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
4545 self.terrain_zones
4546 .retain(|z| !z.id.starts_with("rt:"));
4547 self.terrain_zones.extend(overlays.iter().cloned());
4548 }
4549
4550 fn refresh_whisper_range(&mut self) {
4553 let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
4554 return;
4555 };
4556 let (px, py) = self.player_position();
4557 let in_range = self.entities.iter().any(|e| {
4558 e.id == peer
4559 && distance(
4560 px,
4561 py,
4562 e.transform.position.x,
4563 e.transform.position.y,
4564 ) <= INTERACTION_RADIUS_M
4565 });
4566 if !in_range {
4567 self.social_chat.cancel_whisper_out_of_range();
4568 }
4569 }
4570
4571 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
4573 let (px, py) = self.player_position();
4574 let mut out = Vec::new();
4575 for npc in &self.npcs {
4576 let Some(eid) = npc.entity_id else {
4577 continue;
4578 };
4579 let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
4580 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
4581 if alive && has_hp {
4582 out.push((eid, npc.label.clone()));
4583 }
4584 }
4585 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
4586 let dist = |id: EntityId| {
4587 self.entities
4588 .iter()
4589 .find(|e| e.id == id)
4590 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4591 .unwrap_or(f32::MAX)
4592 };
4593 dist(*a_id)
4594 .partial_cmp(&dist(*b_id))
4595 .unwrap_or(std::cmp::Ordering::Equal)
4596 .then_with(|| a_label.cmp(b_label))
4597 .then_with(|| a_id.cmp(b_id))
4598 });
4599 out
4600 }
4601
4602 pub fn refresh_combat_target_label(&mut self) {
4603 let Some(id) = self.combat_target else {
4604 return;
4605 };
4606 if let Some((_, label)) = self
4607 .combat_candidates()
4608 .into_iter()
4609 .find(|(eid, _)| *eid == id)
4610 {
4611 self.combat_target_label = Some(label);
4612 } else if let Some(label) = self
4613 .entities
4614 .iter()
4615 .find(|e| e.id == id)
4616 .map(|e| e.label.clone())
4617 {
4618 self.combat_target_label = Some(label);
4619 }
4620 }
4621
4622 pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
4623 self.quest_log
4624 .iter()
4625 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
4626 .collect()
4627 }
4628
4629 pub fn has_worker_lodging(&self) -> bool {
4631 self.free_worker_lodging_slots() > 0
4632 }
4633
4634 pub fn free_worker_lodging_slots(&self) -> i64 {
4636 let slots: u32 = self
4637 .placed_containers
4638 .iter()
4639 .filter(|c| match (self.character_id, c.owner_character_id) {
4640 (Some(me), Some(owner)) => me == owner,
4641 (Some(_), None) => false,
4642 (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
4643 })
4644 .map(|c| c.worker_lodging_capacity.unwrap_or(0))
4645 .sum();
4646 let used = self.hired_workers.len() as u32;
4647 slots as i64 - used as i64
4648 }
4649
4650 pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
4652 let mut names: Vec<String> = self
4653 .hired_workers
4654 .iter()
4655 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
4656 .map(|w| w.label.clone())
4657 .collect();
4658 names.sort();
4659 names
4660 }
4661
4662 pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
4664 let is_lodging = self
4665 .placed_containers
4666 .iter()
4667 .find(|c| c.id == container_id)
4668 .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
4669 if !is_lodging {
4670 return None;
4671 }
4672 let names = self.lodging_occupant_labels(container_id);
4673 Some(if names.is_empty() {
4674 "vacant".into()
4675 } else {
4676 names.join(", ")
4677 })
4678 }
4679
4680 pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
4681 self.quest_log
4682 .iter()
4683 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
4684 .or_else(|| {
4685 self.quest_log
4686 .iter()
4687 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
4688 })
4689 }
4690
4691 pub fn nearest_interact_target(&self) -> Option<String> {
4693 let (px, py) = self.player_position();
4694 let inside = self.effective_inside_building();
4695
4696 #[derive(Clone, Copy, PartialEq, Eq)]
4697 enum Kind {
4698 Player,
4699 Npc,
4700 HiredWorker,
4701 QuestBoard,
4702 ExitDoor,
4703 EnterDoor,
4704 Well,
4705 Water,
4706 }
4707
4708 fn kind_priority(kind: Kind) -> u8 {
4709 match kind {
4710 Kind::Player => 0,
4711 Kind::Npc => 0,
4712 Kind::HiredWorker => 0,
4713 Kind::QuestBoard => 1,
4714 Kind::ExitDoor => 2,
4715 Kind::EnterDoor => 3,
4716 Kind::Well => 4,
4717 Kind::Water => 5,
4718 }
4719 }
4720
4721 let mut best: Option<(f32, Kind, String)> = None;
4722
4723 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
4724 if dist > max {
4725 return;
4726 }
4727 let replace = match best {
4728 None => true,
4729 Some((bd, _bk, _)) if dist < bd - 0.05 => true,
4730 Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
4731 kind_priority(kind) < kind_priority(bk)
4732 }
4733 _ => false,
4734 };
4735 if replace {
4736 best = Some((dist, kind, id));
4737 }
4738 };
4739
4740 for npc in &self.npcs {
4741 consider(
4742 distance(px, py, npc.x, npc.y),
4743 INTERACTION_RADIUS_M,
4744 Kind::Npc,
4745 npc.id.clone(),
4746 );
4747 }
4748
4749 for worker in &self.hired_workers {
4750 consider(
4751 distance(px, py, worker.x, worker.y),
4752 INTERACTION_RADIUS_M,
4753 Kind::HiredWorker,
4754 worker.instance_id.clone(),
4755 );
4756 }
4757
4758 for entity in &self.entities {
4759 if entity.id == self.entity_id || entity.vitals.is_none() || entity.label.trim().is_empty()
4760 {
4761 continue;
4762 }
4763 if self
4765 .hired_workers
4766 .iter()
4767 .any(|w| w.entity_id == entity.id)
4768 {
4769 continue;
4770 }
4771 consider(
4772 distance(
4773 px,
4774 py,
4775 entity.transform.position.x,
4776 entity.transform.position.y,
4777 ),
4778 INTERACTION_RADIUS_M,
4779 Kind::Player,
4780 entity.id.to_string(),
4781 );
4782 }
4783
4784 for door in &self.doors {
4785 if let Some(ref bid) = inside {
4786 if door.building_id != *bid {
4787 continue;
4788 }
4789 let is_exit = door.portal.is_some();
4790 let max = if is_exit {
4791 INTERACTION_RADIUS_M
4792 } else {
4793 DOOR_INTERACTION_RADIUS_M
4794 };
4795 let kind = if is_exit {
4796 Kind::ExitDoor
4797 } else {
4798 Kind::EnterDoor
4799 };
4800 consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
4801 continue;
4802 }
4803 consider(
4804 distance(px, py, door.x, door.y),
4805 DOOR_INTERACTION_RADIUS_M,
4806 Kind::EnterDoor,
4807 door.id.clone(),
4808 );
4809 }
4810
4811 if inside.is_none() {
4812 for inter in &self.interactables {
4813 if inter.kind == "quest_board" {
4814 consider(
4815 distance(px, py, inter.x, inter.y),
4816 QUEST_BOARD_INTERACTION_RADIUS_M,
4817 Kind::QuestBoard,
4818 inter.id.clone(),
4819 );
4820 }
4821 }
4822 for building in &self.buildings {
4823 if !building.tags.iter().any(|t| t == "well") {
4824 continue;
4825 }
4826 consider(
4827 distance(px, py, building.x, building.y),
4828 INTERACTION_RADIUS_M,
4829 Kind::Well,
4830 building.id.clone(),
4831 );
4832 }
4833 if self.in_shallow_water() {
4834 consider(
4835 0.0,
4836 INTERACTION_RADIUS_M,
4837 Kind::Water,
4838 "water_source".into(),
4839 );
4840 }
4841 }
4842
4843 best.map(|(_, _, id)| id)
4844 }
4845
4846 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
4848 if self.effective_inside_building().is_some() {
4849 return None;
4850 }
4851 let (px, py) = self.player_position();
4852 self.interactables
4853 .iter()
4854 .filter(|i| i.kind == "quest_board")
4855 .map(|i| {
4856 let label = if i.label.is_empty() {
4857 "Quest board".to_string()
4858 } else {
4859 i.label.clone()
4860 };
4861 (label, distance(px, py, i.x, i.y))
4862 })
4863 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
4864 }
4865
4866 pub fn template_display_name(&self, template_id: &str) -> String {
4868 self.inventory_hints
4869 .get(template_id)
4870 .map(|h| h.display_name.clone())
4871 .filter(|n| !n.is_empty())
4872 .unwrap_or_else(|| humanize_template_id(template_id))
4873 }
4874
4875 pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
4877 use crate::worker_route_editor::{
4878 node_candidates, node_candidates_stable, route_editor_lodging_anchor,
4879 };
4880 let lodging = self
4881 .worker_route_editor
4882 .as_ref()
4883 .and_then(|ed| ed.lodging_container_id.as_deref());
4884 match route_editor_lodging_anchor(lodging, &self.placed_containers) {
4885 Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
4886 None => node_candidates_stable(&self.resource_nodes),
4887 }
4888 }
4889
4890 pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
4891 if dist_m.is_nan() {
4892 return "—".into();
4893 }
4894 let from_bed = self
4895 .worker_route_editor
4896 .as_ref()
4897 .and_then(|ed| ed.lodging_container_id.as_deref())
4898 .and_then(|id| {
4899 self.placed_containers
4900 .iter()
4901 .find(|c| c.id == id)
4902 .map(|c| c.display_name.clone())
4903 });
4904 match from_bed {
4905 Some(bed) => format!("{dist_m:.0}m from {bed}"),
4906 None => format!("{dist_m:.0}m"),
4907 }
4908 }
4909
4910 pub fn placed_container_public_label(
4912 &self,
4913 c: &flatland_protocol::PlacedContainerView,
4914 ) -> String {
4915 let is_owner = match (self.character_id, c.owner_character_id) {
4916 (Some(me), Some(owner)) => me == owner,
4917 _ => false,
4918 };
4919 if is_owner {
4920 c.display_name.clone()
4921 } else {
4922 self.template_display_name(&c.template_id)
4923 }
4924 }
4925
4926 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
4928 let mut out = Vec::new();
4929 for stack in &self.inventory_stacks {
4930 if stack.template_id == KEY_TEMPLATE {
4931 out.push(KeychainEntry {
4932 stack: stack.clone(),
4933 stowed: false,
4934 });
4935 }
4936 }
4937 for stack in &self.keychain_stacks {
4938 if stack.template_id == KEY_TEMPLATE {
4939 out.push(KeychainEntry {
4940 stack: stack.clone(),
4941 stowed: true,
4942 });
4943 }
4944 }
4945 out
4946 }
4947
4948 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
4950 if stack.template_id != KEY_TEMPLATE {
4951 return None;
4952 }
4953 if let Some(name) = stack
4954 .props
4955 .get(PROP_OPENS_CONTAINER_NAME)
4956 .filter(|n| !n.is_empty())
4957 {
4958 return Some(name.clone());
4959 }
4960 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
4961 self.container_name_for_lock_id(opens)
4962 }
4963
4964 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
4966 if stack.template_id == KEY_TEMPLATE {
4967 self.template_display_name(KEY_TEMPLATE)
4968 } else {
4969 stack
4970 .display_name
4971 .clone()
4972 .unwrap_or_else(|| stack.template_id.clone())
4973 }
4974 }
4975
4976 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
4978 if stack.template_id != KEY_TEMPLATE {
4979 return String::new();
4980 }
4981 match self.key_pair_chest_label(stack) {
4982 Some(chest) if self.key_drop_blocked(stack) => {
4983 format!(" [key for {chest} — can't drop while locked]")
4984 }
4985 Some(chest) => format!(" [key for {chest}]"),
4986 None => " [key — unpaired]".into(),
4987 }
4988 }
4989
4990 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
4992 for c in &self.placed_containers {
4993 if c.lock_id.as_deref() == Some(lock) {
4994 return Some(c.display_name.clone());
4995 }
4996 }
4997 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
4998 self.worn
4999 .values()
5000 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
5001 })
5002 }
5003
5004 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
5006 if stack.template_id != KEY_TEMPLATE {
5007 return false;
5008 }
5009 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
5010 return false;
5011 };
5012 for c in &self.placed_containers {
5013 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
5014 return true;
5015 }
5016 }
5017 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
5018 return true;
5019 }
5020 self.worn
5021 .values()
5022 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
5023 }
5024
5025 pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
5027 stack.template_id == PROPERTY_DEED_TEMPLATE
5028 }
5029
5030 pub fn is_property_deed_template(template_id: &str) -> bool {
5031 template_id == PROPERTY_DEED_TEMPLATE
5032 }
5033
5034 pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
5035 stack
5036 .props
5037 .get("plot_id")
5038 .and_then(|s| uuid::Uuid::parse_str(s).ok())
5039 }
5040
5041 pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
5043 let (px, py) = self.player_position();
5044 let (cx, cy) = self.farm_plot_cell_under_player()?;
5045 let tx = cx as f32 + 0.5;
5046 let ty = cy as f32 + 0.5;
5047 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5048 return None;
5049 }
5050 let kind = self
5051 .terrain_at(tx, ty)
5052 .or_else(|| self.terrain_at(px, py));
5053 if kind == Some(TerrainKindView::Tilled) {
5054 return None;
5055 }
5056 if matches!(
5057 kind,
5058 Some(TerrainKindView::ShallowWater)
5059 | Some(TerrainKindView::DeepWater)
5060 | Some(TerrainKindView::Rock)
5061 ) {
5062 return None;
5063 }
5064 Some((tx, ty))
5065 }
5066
5067 fn container_name_in_stacks(
5068 stacks: &[flatland_protocol::ItemStack],
5069 lock: &str,
5070 ) -> Option<String> {
5071 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
5072 for s in stacks {
5073 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
5074 return Some(GameState::stack_container_label(s));
5075 }
5076 if let Some(name) = walk(&s.contents, lock) {
5077 return Some(name);
5078 }
5079 }
5080 None
5081 }
5082 walk(stacks, lock)
5083 }
5084
5085 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
5086 stack
5087 .props
5088 .get(PROP_CUSTOM_NAME)
5089 .cloned()
5090 .or_else(|| stack.display_name.clone())
5091 .unwrap_or_else(|| stack.template_id.clone())
5092 }
5093
5094 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5095 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5096 for s in stacks {
5097 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
5098 return true;
5099 }
5100 if walk(&s.contents, lock) {
5101 return true;
5102 }
5103 }
5104 false
5105 }
5106 walk(stacks, lock)
5107 }
5108
5109 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
5110 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
5111 return Some(stack.clone());
5112 }
5113 for worn in self.worn.values() {
5114 if worn.item_instance_id == Some(instance_id) {
5115 return Some(worn.clone());
5116 }
5117 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
5118 return Some(stack.clone());
5119 }
5120 }
5121 None
5122 }
5123
5124 pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
5126 self.property_zones
5127 .iter()
5128 .enumerate()
5129 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5130 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5131 .map(|(_, z)| z)
5132 }
5133
5134 pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
5136 self.tax_zones
5137 .iter()
5138 .enumerate()
5139 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5140 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5141 .map(|(_, z)| z)
5142 }
5143
5144 pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
5146 let mut max_bps = 0u32;
5147 let mut y = y0 + 0.5;
5148 while y < y1 {
5149 let mut x = x0 + 0.5;
5150 while x < x1 {
5151 if let Some(tz) = self.tax_zone_at(x, y) {
5152 max_bps = max_bps.max(tz.rate_bps);
5153 }
5154 x += 1.0;
5155 }
5156 y += 1.0;
5157 }
5158 max_bps
5159 }
5160
5161 pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5163 let mode = self.claim_mode.as_ref()?;
5164 let w = mode.width_m.max(1) as f32;
5165 let h = mode.height_m.max(1) as f32;
5166 Some((mode.anchor_x, mode.anchor_y, mode.anchor_x + w, mode.anchor_y + h))
5167 }
5168
5169 pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5171 let mode = self.relocate_mode.as_ref()?;
5172 let x0 = mode.cursor_x.floor();
5173 let y0 = mode.cursor_y.floor();
5174 Some((x0, y0, x0 + 1.0, y0 + 1.0))
5175 }
5176
5177 pub fn claim_quote(
5180 &self,
5181 ) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
5182 let mode = self.claim_mode.as_ref()?;
5183 let zone = self
5184 .property_zones
5185 .iter()
5186 .find(|z| z.id == mode.zone_id)?;
5187 let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
5188 let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
5189 let zone_area = zone_view_area_m2(zone).max(1.0);
5190 let area_frac = (area / zone_area).clamp(0.0, 1.0);
5191 let weight = self
5192 .property_plot_settings
5193 .as_ref()
5194 .map(|s| s.tax_premium_weight)
5195 .unwrap_or(0.5)
5196 .max(0.0);
5197 let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
5198 let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
5199 let purchase = ((zone.crown_price_copper as f64)
5200 * (area_frac as f64)
5201 * (premium as f64))
5202 .ceil()
5203 .max(0.0) as u64;
5204 let upkeep = if zone.upkeep_copper_per_day == 0 {
5205 0
5206 } else {
5207 ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
5208 .ceil()
5209 .max(1.0) as u64
5210 };
5211 let copper = crate::currency::copper_from_counts(&self.inventory);
5212 let can_afford = copper >= purchase;
5213 let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
5214 Some((purchase, upkeep, area, premium, can_afford, valid, reason))
5215 }
5216
5217 fn validate_claim_footprint(
5218 &self,
5219 zone: &flatland_protocol::PropertyZoneView,
5220 x0: f32,
5221 y0: f32,
5222 x1: f32,
5223 y1: f32,
5224 area: f32,
5225 ) -> (bool, String) {
5226 let min_area = self
5227 .property_plot_settings
5228 .as_ref()
5229 .map(|s| s.min_plot_area_m2)
5230 .unwrap_or(4.0);
5231 if area + f32::EPSILON < min_area {
5232 return (false, "plot too small".into());
5233 }
5234 if zone.max_area_m2.is_some_and(|m| area > m) {
5235 return (false, "plot exceeds max area".into());
5236 }
5237 if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
5238 return (false, "plot must lie inside the property zone".into());
5239 }
5240 if self.property_plots.iter().any(|p| {
5241 rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1)
5242 }) {
5243 return (false, "plot overlaps an existing claim".into());
5244 }
5245 (true, String::new())
5246 }
5247
5248 pub fn free_property_zone_under_player(
5250 &self,
5251 ) -> Option<&flatland_protocol::PropertyZoneView> {
5252 let (px, py) = self.player_position();
5253 let zone = self.property_zone_at(px, py)?;
5254 if self
5255 .property_plots
5256 .iter()
5257 .any(|p| point_in_plot(px, py, p))
5258 {
5259 return None;
5260 }
5261 Some(zone)
5262 }
5263
5264 pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5266 let (px, py) = self.player_position();
5267 self.property_plots
5268 .iter()
5269 .find(|p| p.is_mine && point_in_plot(px, py, p))
5270 }
5271
5272 pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5274 let (px, py) = self.player_position();
5275 self.property_plots
5276 .iter()
5277 .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
5278 }
5279
5280 pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
5282 if self.farmable_plot_under_player().is_none() {
5283 return None;
5284 }
5285 let (px, py) = self.player_position();
5286 Some((px.floor() as i32, py.floor() as i32))
5287 }
5288
5289 fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
5290 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5291 self.resource_nodes.iter().any(|n| {
5292 let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
5293 ncx == cx && ncy == cy
5294 || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
5295 })
5296 }
5297
5298 fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
5299 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5300 let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
5301 || self
5302 .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
5303 .is_some_and(|z| z.kind == TerrainKindView::Tilled);
5304 if !tilled {
5305 return false;
5306 }
5307 !self.resource_node_occupies_farm_cell(cx, cy)
5308 }
5309
5310 pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
5312 let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
5313 return false;
5314 };
5315 self.free_tilled_plant_slot_at(cx, cy)
5316 }
5317
5318 pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
5320 let (px, py) = self.player_position();
5321 for dy in -2..=2 {
5322 for dx in -2..=2 {
5323 let cx = px.floor() as i32 + dx;
5324 let cy = py.floor() as i32 + dy;
5325 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5326 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5327 continue;
5328 }
5329 if self.free_tilled_plant_slot_at(cx, cy) {
5330 return true;
5331 }
5332 }
5333 }
5334 false
5335 }
5336
5337 fn stack_is_farm_seed(stack: &flatland_protocol::ItemStack) -> bool {
5338 stack.quantity > 0
5339 && (stack.props.contains_key("seed_for")
5340 || stack.template_id.ends_with("_seed")
5341 || stack.template_id == "potato_seed"
5342 || stack.template_id == "carrot_seed")
5343 }
5344
5345 pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
5347 let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
5348 fn walk(
5349 stacks: &[flatland_protocol::ItemStack],
5350 counts: &mut std::collections::HashMap<String, u32>,
5351 ) {
5352 for s in stacks {
5353 if GameState::stack_is_farm_seed(s) {
5354 *counts.entry(s.template_id.clone()).or_default() += s.quantity;
5355 }
5356 walk(&s.contents, counts);
5357 }
5358 }
5359 walk(&self.inventory_stacks, &mut counts);
5360 for worn in self.worn.values() {
5361 walk(std::slice::from_ref(worn), &mut counts);
5362 }
5363 let mut out: Vec<_> = counts
5364 .into_iter()
5365 .map(|(template_id, quantity)| {
5366 let label = self
5367 .inventory_hints
5368 .get(&template_id)
5369 .map(|h| h.display_name.clone())
5370 .filter(|n| !n.trim().is_empty())
5371 .unwrap_or_else(|| humanize_template_id(&template_id));
5372 (template_id, quantity, label)
5373 })
5374 .collect();
5375 out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
5376 out
5377 }
5378
5379 pub fn first_farm_seed_template(&self) -> Option<String> {
5381 self.farm_seed_entries()
5382 .into_iter()
5383 .next()
5384 .map(|(id, _, _)| id)
5385 }
5386
5387 pub fn clamp_plant_menu(&mut self) {
5388 let n = self.farm_seed_entries().len();
5389 if n == 0 {
5390 self.plant_menu_index = 0;
5391 self.plant_quantity = 1;
5392 return;
5393 }
5394 self.plant_menu_index = self.plant_menu_index.min(n - 1);
5395 let max_qty = self
5396 .farm_seed_entries()
5397 .get(self.plant_menu_index)
5398 .map(|(_, q, _)| *q)
5399 .unwrap_or(1)
5400 .max(1);
5401 self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
5402 }
5403
5404 pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
5405 let entries = self.farm_seed_entries();
5406 let (id, max, label) = entries.get(self.plant_menu_index)?;
5407 let qty = self.plant_quantity.min(*max).max(1);
5408 Some((id.clone(), qty, label.clone()))
5409 }
5410
5411 pub fn location_context_lines(&self) -> Vec<ContextLine> {
5413 let (px, py) = self.player_position();
5414 let inside = self.effective_inside_building();
5415 let mut lines = Vec::new();
5416
5417 if let Some(kind) = self.terrain_at(px, py) {
5418 lines.push(ContextLine {
5419 on_top: true,
5420 text: format!("Terrain: {}", terrain_kind_label(kind)),
5421 });
5422 }
5423
5424 if let Some(id) = inside.as_ref() {
5425 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
5426 lines.push(ContextLine {
5427 on_top: true,
5428 text: format!("Inside: {}", b.label),
5429 });
5430 }
5431 }
5432
5433 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
5434
5435 for node in &self.resource_nodes {
5436 if node.id.starts_with("preview:") {
5437 continue;
5438 }
5439 let dist = distance(px, py, node.x, node.y);
5440 if dist > NEARBY_SCAN_M {
5441 continue;
5442 }
5443 let on_top = dist <= ON_TOP_RADIUS_M;
5444 let prefix = if on_top { "On" } else { "Near" };
5445 let name = resource_node_near_display_label(&node.label);
5446 let action = resource_node_near_action_suffix(node);
5447 nearby.push((
5448 dist,
5449 ContextLine {
5450 on_top,
5451 text: format!("{prefix}: {name} ({dist:.1}m){action}"),
5452 },
5453 ));
5454 }
5455
5456 for drop in &self.ground_drops {
5457 let dist = distance(px, py, drop.x, drop.y);
5458 if dist > INTERACTION_RADIUS_M {
5459 continue;
5460 }
5461 let on_top = dist <= ON_TOP_RADIUS_M;
5462 let name = self.template_display_name(&drop.template_id);
5463 let prefix = if on_top { "On" } else { "Near" };
5464 let qty = if drop.quantity > 1 {
5465 format!(" ×{}", drop.quantity)
5466 } else {
5467 String::new()
5468 };
5469 nearby.push((
5470 dist,
5471 ContextLine {
5472 on_top,
5473 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
5474 },
5475 ));
5476 }
5477
5478 for c in &self.placed_containers {
5479 let dist = distance(px, py, c.x, c.y);
5480 if dist > CONTAINER_RANGE_M {
5481 continue;
5482 }
5483 let on_top = dist <= ON_TOP_RADIUS_M;
5484 let name = self.placed_container_public_label(c);
5485 let lock = if c.locked { " [locked]" } else { "" };
5486 let prefix = if on_top { "On" } else { "Near" };
5487 nearby.push((
5488 dist,
5489 ContextLine {
5490 on_top,
5491 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
5492 },
5493 ));
5494 }
5495
5496 for npc in &self.npcs {
5497 let dist = distance(px, py, npc.x, npc.y);
5498 if dist > NEARBY_SCAN_M {
5499 continue;
5500 }
5501 let on_top = dist <= ON_TOP_RADIUS_M;
5502 let prefix = if on_top { "On" } else { "Near" };
5503 nearby.push((
5504 dist,
5505 ContextLine {
5506 on_top,
5507 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
5508 },
5509 ));
5510 }
5511
5512 for door in &self.doors {
5513 let dist = distance(px, py, door.x, door.y);
5514 if dist > DOOR_INTERACTION_RADIUS_M {
5515 continue;
5516 }
5517 let building = self
5518 .buildings
5519 .iter()
5520 .find(|b| b.id == door.building_id)
5521 .map(|b| b.label.as_str())
5522 .unwrap_or(door.building_id.as_str());
5523 let action = if inside.is_some() && door.portal.is_some() {
5524 "exit"
5525 } else {
5526 "enter"
5527 };
5528 nearby.push((
5529 dist,
5530 ContextLine {
5531 on_top: dist <= ON_TOP_RADIUS_M,
5532 text: format!("{building} door ({dist:.1}m) — f {action}"),
5533 },
5534 ));
5535 }
5536
5537 if inside.is_none() {
5538 for inter in &self.interactables {
5539 if inter.kind != "quest_board" {
5540 continue;
5541 }
5542 let dist = distance(px, py, inter.x, inter.y);
5543 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
5544 continue;
5545 }
5546 let on_top = dist <= ON_TOP_RADIUS_M;
5547 let prefix = if on_top { "On" } else { "Near" };
5548 let label = if inter.label.is_empty() {
5549 "Quest board".to_string()
5550 } else {
5551 inter.label.clone()
5552 };
5553 nearby.push((
5554 dist,
5555 ContextLine {
5556 on_top,
5557 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
5558 },
5559 ));
5560 }
5561 }
5562
5563 if self.in_shallow_water() {
5564 let already = self
5565 .terrain_at(px, py)
5566 .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
5567 if !already {
5568 nearby.push((
5569 0.0,
5570 ContextLine {
5571 on_top: true,
5572 text: "Shallow water — f fill bottle".into(),
5573 },
5574 ));
5575 } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
5576 line.text.push_str(" — f fill bottle");
5577 }
5578 }
5579
5580 if self.claim_mode.is_some() {
5581 nearby.push((
5582 0.0,
5583 ContextLine {
5584 on_top: true,
5585 text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
5586 .into(),
5587 },
5588 ));
5589 } else if let Some(plot) = self.my_plot_under_player() {
5590 let zone = plot
5591 .zone_label
5592 .as_deref()
5593 .filter(|s| !s.trim().is_empty())
5594 .or_else(|| {
5595 self.property_zones
5596 .iter()
5597 .find(|z| z.id == plot.property_zone_id)
5598 .and_then(|z| z.label.as_deref().filter(|s| !s.trim().is_empty()))
5599 })
5600 .unwrap_or(plot.property_zone_id.as_str());
5601 let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
5602 format!("Your plot ({zone}) — f again to sell to crown")
5603 } else {
5604 format!(
5605 "Your plot ({zone}) — c till · p plant · f harvest · o farm access · deed to sell"
5606 )
5607 };
5608 nearby.push((
5609 0.0,
5610 ContextLine {
5611 on_top: true,
5612 text: prompt,
5613 },
5614 ));
5615 } else if let Some(plot) = self.farmable_plot_under_player() {
5616 let owner = plot
5617 .owner_label
5618 .as_deref()
5619 .filter(|s| !s.trim().is_empty())
5620 .unwrap_or("owner");
5621 let disc = if plot.farm_public {
5622 plot.public_tax_discount_bps / 100
5623 } else {
5624 plot.farm_allow
5625 .iter()
5626 .find(|g| Some(g.character_id) == self.character_id)
5627 .map(|g| g.tax_discount_bps / 100)
5628 .unwrap_or(0)
5629 };
5630 nearby.push((
5631 0.0,
5632 ContextLine {
5633 on_top: true,
5634 text: format!(
5635 "Farming permitted — {owner} (tax −{disc}%) — c till · p plant · f harvest"
5636 ),
5637 },
5638 ));
5639 } else if let Some(zone) = self.free_property_zone_under_player() {
5640 let label = zone
5641 .label
5642 .as_deref()
5643 .filter(|s| !s.trim().is_empty())
5644 .unwrap_or(zone.id.as_str());
5645 nearby.push((
5646 0.0,
5647 ContextLine {
5648 on_top: true,
5649 text: format!("Claimable land: {label} — k buy plot"),
5650 },
5651 ));
5652 }
5653
5654 for entity in &self.entities {
5655 if entity.id == self.entity_id {
5656 continue;
5657 }
5658 let dist = distance(
5659 px,
5660 py,
5661 entity.transform.position.x,
5662 entity.transform.position.y,
5663 );
5664 if dist > NEARBY_SCAN_M {
5665 continue;
5666 }
5667 let label = if entity.label.is_empty() {
5668 format!("entity {}", entity.id)
5669 } else {
5670 entity.label.clone()
5671 };
5672 nearby.push((
5673 dist,
5674 ContextLine {
5675 on_top: dist <= ON_TOP_RADIUS_M,
5676 text: format!("Near: {label} ({dist:.1}m)"),
5677 },
5678 ));
5679 }
5680
5681 nearby.sort_by(|a, b| {
5682 a.0.partial_cmp(&b.0)
5683 .unwrap_or(std::cmp::Ordering::Equal)
5684 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
5685 });
5686 lines.extend(nearby.into_iter().map(|(_, l)| l));
5687
5688 if lines.is_empty() {
5689 lines.push(ContextLine {
5690 on_top: false,
5691 text: "(nothing notable nearby)".into(),
5692 });
5693 }
5694
5695 lines
5696 }
5697}
5698
5699#[derive(Debug, Clone)]
5701pub struct ContextLine {
5702 pub on_top: bool,
5703 pub text: String,
5704}
5705
5706const ON_TOP_RADIUS_M: f32 = 0.65;
5707const NEARBY_SCAN_M: f32 = 5.0;
5708
5709pub fn resource_node_near_display_label(label: &str) -> String {
5711 label
5712 .strip_suffix(" (growing)")
5713 .unwrap_or(label)
5714 .to_string()
5715}
5716
5717pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
5719 use flatland_protocol::ResourceNodeState;
5720 if let Some(p) = node.growth_progress {
5721 if p < 1.0 - f32::EPSILON {
5722 let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
5723 return format!(" (growing, {pct}%)");
5724 }
5725 return " — f harvest".to_string();
5726 }
5727 match node.state {
5728 ResourceNodeState::Available => " — f harvest".to_string(),
5729 ResourceNodeState::Harvesting => " (being harvested)".to_string(),
5730 ResourceNodeState::Cooldown => " (depleted)".to_string(),
5731 }
5732}
5733
5734fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
5735 use flatland_protocol::TerrainKindView;
5736 match kind {
5737 TerrainKindView::Grass => "Grass",
5738 TerrainKindView::Dirt => "Dirt",
5739 TerrainKindView::Tilled => "Tilled",
5740 TerrainKindView::Desert => "Desert",
5741 TerrainKindView::Hill => "Hills",
5742 TerrainKindView::Bog => "Bog",
5743 TerrainKindView::Beach => "Beach",
5744 TerrainKindView::ShallowWater => "Shallow water",
5745 TerrainKindView::DeepWater => "Deep water",
5746 TerrainKindView::Trail => "Trail",
5747 TerrainKindView::Road => "Road",
5748 TerrainKindView::Rock => "Rock",
5749 }
5750}
5751
5752fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
5753 crate::world_zones::zone_rects_contain(rects, x, y)
5754}
5755
5756fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
5757 zone.rects
5758 .iter()
5759 .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
5760 .sum()
5761}
5762
5763fn claim_rect_fully_inside_zone(
5764 zone: &flatland_protocol::PropertyZoneView,
5765 x0: f32,
5766 y0: f32,
5767 x1: f32,
5768 y1: f32,
5769) -> bool {
5770 let mut y = y0 + 0.5;
5771 while y < y1 {
5772 let mut x = x0 + 0.5;
5773 while x < x1 {
5774 if !zone_rects_contain(&zone.rects, x, y) {
5775 return false;
5776 }
5777 x += 1.0;
5778 }
5779 y += 1.0;
5780 }
5781 true
5782}
5783
5784fn rects_overlap_half_open(
5785 ax0: f32,
5786 ay0: f32,
5787 ax1: f32,
5788 ay1: f32,
5789 bx0: f32,
5790 by0: f32,
5791 bx1: f32,
5792 by1: f32,
5793) -> bool {
5794 ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
5795}
5796
5797fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
5798 x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
5799}
5800
5801fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
5802 p.zone_label
5803 .as_deref()
5804 .filter(|s| !s.trim().is_empty())
5805 .map(|s| s.to_string())
5806 .unwrap_or_else(|| format!("plot {}", &p.plot_id.to_string()[..8]))
5807}
5808
5809fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
5811 let a = x0.min(x1).floor();
5812 let b = y0.min(y1).floor();
5813 let mut c = x0.max(x1).ceil();
5814 let mut d = y0.max(y1).ceil();
5815 if (c - a) < 1.0 {
5816 c = a + 1.0;
5817 }
5818 if (d - b) < 1.0 {
5819 d = b + 1.0;
5820 }
5821 (a, b, c, d)
5822}
5823
5824fn humanize_template_id(template_id: &str) -> String {
5825 template_id
5826 .split('_')
5827 .map(|word| {
5828 let mut chars = word.chars();
5829 match chars.next() {
5830 None => String::new(),
5831 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
5832 }
5833 })
5834 .collect::<Vec<_>>()
5835 .join(" ")
5836}
5837
5838const HARVEST_RANGE_M: f32 = 1.5;
5840
5841pub struct GameClient<S: PlayConnection> {
5842 session: S,
5843 seq: Seq,
5844 pub state: GameState,
5845 last_move_forward: f32,
5846 last_move_strafe: f32,
5847}
5848
5849impl<S: PlayConnection> GameClient<S> {
5850 pub fn new(session: S) -> Self {
5851 let session_id = session.session_id();
5852 let entity_id = session.entity_id();
5853 let mut client = Self {
5854 session,
5855 seq: 0,
5856 last_move_forward: 0.0,
5857 last_move_strafe: 0.0,
5858 state: GameState {
5859 session_id,
5860 entity_id,
5861 character_id: None,
5862 tick: 0,
5863 chunk_rev: 0,
5864 content_rev: 0,
5865 publish_rev: 0,
5866 entities: Vec::new(),
5867 player: None,
5868 resource_nodes: Vec::new(),
5869 ground_drops: Vec::new(),
5870 placed_containers: Vec::new(),
5871 buildings: Vec::new(),
5872 doors: Vec::new(),
5873 interior_map: None,
5874 npcs: Vec::new(),
5875 blueprints: Vec::new(),
5876 world_x0: 0.0,
5877 world_y0: 0.0,
5878 world_width_m: 0.0,
5879 world_height_m: 0.0,
5880 terrain_zones: Vec::new(),
5881 z_platforms: Vec::new(),
5882 z_transitions: Vec::new(),
5883 z_bands_outdoor_backup: None,
5884 world_clock: flatland_protocol::WorldClock::default(),
5885 inventory: std::collections::HashMap::new(),
5886 inventory_hints: std::collections::HashMap::new(),
5887 logs: VecDeque::new(),
5888 intents_sent: 0,
5889 ticks_received: 0,
5890 connected: false,
5891 disconnect_reason: None,
5892 show_stats: false,
5893 hud_log_hidden: false,
5894 show_equip_menu: false,
5895 equip_menu_index: 0,
5896 show_craft_menu: false,
5897 craft_menu_index: 0,
5898 craft_batch_quantity: 1,
5899 show_shop_menu: false,
5900 shop_catalog: None,
5901 bank_panel: None,
5902 bank_menu_index: 0,
5903 bank_ui_mode: BankUiMode::Menu,
5904 storage_panel: None,
5905 market_panel: None,
5906 market_menu_index: 0,
5907 market_filter: String::new(),
5908 market_filter_focused: false,
5909 market_category_filter: None,
5910 market_buy_confirm: None,
5911 market_ui_mode: MarketUiMode::Browse,
5912 storage_menu_index: 0,
5913 storage_ui_mode: StorageUiMode::Menu,
5914 shop_tab: ShopTab::default(),
5915 shop_menu_index: 0,
5916 shop_quantity: 1,
5917 shop_trade_log: VecDeque::new(),
5918 show_npc_verb_menu: false,
5919 npc_verb_target: None,
5920 npc_verb_index: 0,
5921 player_verbs: crate::social::PlayerVerbState::default(),
5922 social_chat: crate::social::SocialChatState::default(),
5923 trade_ui: crate::social::TradeUiState::default(),
5924 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
5925 show_npc_chat: false,
5926 npc_chat: None,
5927 show_inventory_menu: false,
5928 inventory_menu_index: 0,
5929 inventory_tab: InventoryTab::OnPerson,
5930 inventory_filter: String::new(),
5931 inventory_filter_focused: false,
5932 show_move_picker: false,
5933 show_rename_prompt: false,
5934 show_worker_rename: false,
5935 rename_buffer: String::new(),
5936 move_picker_index: 0,
5937 move_picker: None,
5938 show_grant_picker: false,
5939 grant_picker_index: 0,
5940 grant_picker: None,
5941 show_destroy_picker: false,
5942 destroy_confirm_pending: false,
5943 destroy_picker: None,
5944 combat_target: None,
5945 combat_target_label: None,
5946 ground_target: None,
5947 combat_fx: Vec::new(),
5948 property_zones: Vec::new(),
5949 tax_zones: Vec::new(),
5950 growth_zones: Vec::new(),
5951 biome_zones: Vec::new(),
5952 property_plots: Vec::new(),
5953 property_plot_settings: None,
5954 claim_mode: None,
5955 relocate_mode: None,
5956 sell_plot_confirm: None,
5957 sell_plot_armed_at: None,
5958 show_plant_menu: false,
5959 plant_menu_index: 0,
5960 show_farm_access: false,
5961 farm_access_name_draft: String::new(),
5962 farm_access_discount_bps: 0,
5963 farm_access_index: 0,
5964 plant_quantity: 1,
5965 in_combat: false,
5966 auto_attack: true,
5967 combat_has_los: false,
5968 attack_cd_ticks: 0,
5969 gcd_ticks: 0,
5970 weapon_ability_id: "unarmed".into(),
5971 mainhand_template_id: None,
5972 mainhand_label: None,
5973 offhand_template_id: None,
5974 offhand_label: None,
5975 mainhand_hand_slots: 1,
5976 defense: None,
5977 worn: BTreeMap::new(),
5978 carry_mass: 0.0,
5979 carry_mass_max: 0.0,
5980 encumbrance: flatland_protocol::EncumbranceState::Light,
5981 inventory_stacks: Vec::new(),
5982 keychain_stacks: Vec::new(),
5983 whisper_pouch_stacks: Vec::new(),
5984 combat_target_detail: None,
5985 statuses: Vec::new(),
5986 cast_progress: None,
5987 timed_channel: None,
5988 ability_cooldowns: Vec::new(),
5989 blocking_active: false,
5990 max_target_slots: 1,
5991 combat_slots: Vec::new(),
5992 rotation_presets: Vec::new(),
5993 known_abilities: Vec::new(),
5994 ability_meta: std::collections::HashMap::new(),
5995 hotbar: vec![None; 9],
5996 max_abilities_per_rotation: 0,
5997 show_loadout_menu: false,
5998 show_keychain_menu: false,
5999 keychain_menu_index: 0,
6000 show_rotation_editor: false,
6001 loadout_menu_index: 0,
6002 loadout_hotbar_slot: 1,
6003 loadout_ability_index: 0,
6004 loadout_focus_presets: false,
6005 rotation_editor: RotationEditorState::default(),
6006 harvest_in_progress: false,
6007 harvest_started_at: None,
6008 pending_craft_ack: None,
6009 pending_worker_job_ack: None,
6010 attending_worker_instance_id: None,
6011 quest_log: Vec::new(),
6012 interactables: Vec::new(),
6013 ledger: None,
6014 career: None,
6015 character_sheet_tab: CharacterSheetTab::Character,
6016 ledger_period: LedgerPeriod::Day,
6017 show_quest_offer: false,
6018 pending_quest_offer: None,
6019 show_quest_menu: false,
6020 quest_menu_index: 0,
6021 quest_withdraw_confirm: false,
6022 hired_workers: Vec::new(),
6023 show_workers_menu: false,
6024 workers_menu_index: 0,
6025 workers_menu_compact: false,
6026 worker_step_display: BTreeMap::new(),
6027 worker_error_display: BTreeMap::new(),
6028 show_worker_give_picker: false,
6029 worker_give_picker_index: 0,
6030 worker_give_picker: None,
6031 show_worker_give_target_picker: false,
6032 worker_give_target_picker_index: 0,
6033 worker_give_target_picker: None,
6034 show_worker_take_picker: false,
6035 worker_take_picker_index: 0,
6036 worker_take_picker: None,
6037 show_worker_teach_picker: false,
6038 worker_teach_picker_index: 0,
6039 worker_teach_picker: None,
6040 worker_route_editor: None,
6041 progression_curve: None,
6042 },
6043 };
6044 client.state.apply_client_ui_prefs();
6045 client
6046 }
6047
6048 pub fn entity_id(&self) -> EntityId {
6049 self.state.entity_id
6050 }
6051
6052 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
6053 if self.state.connected {
6054 return Ok(());
6055 }
6056
6057 loop {
6058 match self.session.next_event().await {
6059 Some(SessionEvent::Welcome {
6060 session_id,
6061 entity_id,
6062 snapshot,
6063 }) => {
6064 self.state
6065 .restore_from_welcome(session_id, entity_id, &snapshot);
6066 self.state.apply_client_ui_prefs();
6067 self.state.push_log(format!(
6068 "Connected — session {session_id}, entity {entity_id}"
6069 ));
6070 return Ok(());
6071 }
6072 Some(SessionEvent::Disconnected { .. }) => {
6073 anyhow::bail!("disconnected before welcome");
6074 }
6075 Some(_) => continue,
6076 None => anyhow::bail!("session closed before welcome"),
6077 }
6078 }
6079 }
6080
6081 pub fn drain_events(&mut self) {
6083 while let Some(event) = self.session.try_next_event() {
6084 if self.handle_event_sync(event).is_err() {
6085 break;
6086 }
6087 }
6088 }
6089
6090 pub async fn next_event(&mut self) -> Option<SessionEvent> {
6092 self.session.next_event().await
6093 }
6094
6095 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6096 self.handle_event_sync(event)
6097 }
6098
6099 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6100 match event {
6101 SessionEvent::Welcome {
6102 session_id,
6103 entity_id,
6104 snapshot,
6105 } => {
6106 let resumed = self.state.connected;
6107 self.state
6108 .restore_from_welcome(session_id, entity_id, &snapshot);
6109 if resumed {
6110 self.state.push_log(format!(
6111 "Session restored — session {session_id}, entity {entity_id}"
6112 ));
6113 }
6114 }
6115 SessionEvent::ContentUpdated { snapshot } => {
6116 self.state
6117 .apply_snapshot_fields(&snapshot, self.state.entity_id);
6118 self.state.push_log(format!(
6119 "World updated (content rev {})",
6120 snapshot.content_rev
6121 ));
6122 }
6123 SessionEvent::Tick(delta) => {
6124 self.state.apply_tick_fields(&delta, self.state.entity_id);
6125 self.state.ticks_received += 1;
6126 }
6127 SessionEvent::IntentAck {
6128 entity_id,
6129 seq,
6130 tick,
6131 } => {
6132 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
6133 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
6134 if *craft_seq == seq {
6135 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
6136 if batches > 1 {
6137 self.state.push_log(format!("Crafting {label} ×{batches}…"));
6138 } else {
6139 self.state.push_log(format!("Crafting {label}…"));
6140 }
6141 }
6142 }
6143 if self
6144 .state
6145 .pending_worker_job_ack
6146 .as_ref()
6147 .is_some_and(|p| p.seq == seq)
6148 {
6149 let pending = self.state.pending_worker_job_ack.take().unwrap();
6150 if pending.idle {
6151 self.state.push_log(format!(
6152 "Route cleared for {} — worker idle",
6153 pending.worker_label
6154 ));
6155 } else {
6156 self.state.push_log(format!(
6157 "Route saved for {} — {} stop(s), job loop active",
6158 pending.worker_label, pending.stop_count
6159 ));
6160 }
6161 if self
6162 .state
6163 .worker_route_editor
6164 .as_ref()
6165 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
6166 {
6167 self.close_worker_route_editor();
6168 }
6169 }
6170 }
6171 SessionEvent::Chat(msg) => {
6172 let label = match msg.channel {
6173 flatland_protocol::ChatChannel::Nearby => "nearby",
6174 flatland_protocol::ChatChannel::Direct => "speak",
6175 flatland_protocol::ChatChannel::Whisper => "whisper",
6176 flatland_protocol::ChatChannel::WhisperStone => "stone",
6177 };
6178 let clarity = match msg.clarity {
6179 flatland_protocol::ChatClarity::Clear => "",
6180 flatland_protocol::ChatClarity::Partial => "~",
6181 flatland_protocol::ChatClarity::Heavy => "…",
6182 };
6183 self.state.push_log(format!(
6184 "[{label}{clarity}] {}: {}",
6185 msg.from_name, msg.text
6186 ));
6187 let now_ms = std::time::SystemTime::now()
6188 .duration_since(std::time::UNIX_EPOCH)
6189 .map(|d| d.as_millis() as u64)
6190 .unwrap_or(0);
6191 self.state
6192 .social_chat
6193 .note_speech(&msg, self.state.entity_id, now_ms);
6194 self.state
6195 .social_chat
6196 .push(crate::social::ChatLogEntry::from_message(
6197 msg,
6198 self.state.entity_id,
6199 ));
6200 }
6201 SessionEvent::TradeOpened(panel) => {
6202 self.state.social_chat.pending_trade = None;
6203 let peer = panel.peer_name.clone();
6204 self.state.trade_ui.open(panel);
6205 self.state
6206 .social_chat
6207 .push_system(format!("Trade open with {peer} — p present · r ready · Esc cancel"));
6208 self.state
6209 .social_chat
6210 .push_cue(crate::social::AudioCue::TradeOpened);
6211 }
6212 SessionEvent::TradeClosed { reason } => {
6213 self.state.push_log(reason.clone());
6214 self.state.social_chat.push_system(reason);
6215 self.state.trade_ui.close();
6216 }
6217 SessionEvent::HarvestResult(result) => {
6218 self.state.clear_harvest_state();
6219 crate::harvest_trace!(
6220 entity_id = self.state.entity_id,
6221 node_id = %result.node_id,
6222 template = %result.item_template,
6223 quantity = result.quantity,
6224 client_tick = self.state.tick,
6225 "client applied harvest result"
6226 );
6227 let msg = if result.quantity == 0 {
6228 format!(
6229 "Harvested {} x0 — nothing dropped (loot table rolled empty)",
6230 result.item_template
6231 )
6232 } else {
6233 format!(
6234 "Harvested {} x{} (on the ground — press P to pick up)",
6235 result.item_template, result.quantity
6236 )
6237 };
6238 self.state.push_log(msg);
6239 }
6240 SessionEvent::CraftResult(result) => {
6241 for stack in &result.consumed {
6242 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
6243 *qty = qty.saturating_sub(stack.quantity);
6244 if *qty == 0 {
6245 self.state.inventory.remove(&stack.template_id);
6246 }
6247 }
6248 }
6249 for stack in &result.outputs {
6250 *self
6251 .state
6252 .inventory
6253 .entry(stack.template_id.clone())
6254 .or_insert(0) += stack.quantity;
6255 }
6256 if let Some(output) = result.outputs.first() {
6257 if result.batch_total > 1 {
6258 self.state.push_log(format!(
6259 "Crafted {} x{} ({}/{})",
6260 output.template_id,
6261 output.quantity,
6262 result.batch_index,
6263 result.batch_total
6264 ));
6265 } else {
6266 self.state.push_log(format!(
6267 "Crafted {} x{}",
6268 output.template_id, output.quantity
6269 ));
6270 }
6271 } else {
6272 self.state
6273 .push_log(format!("Craft finished: {}", result.blueprint_id));
6274 }
6275 }
6276 SessionEvent::Death(notice) => {
6277 self.state.clear_harvest_state();
6278 self.state.push_log(notice.message.clone());
6279 self.state.push_log(format!(
6280 "Respawned at ({:.1}, {:.1})",
6281 notice.respawn_x, notice.respawn_y
6282 ));
6283 }
6284 SessionEvent::Interaction(notice) => {
6285 if notice.message.starts_with("Harvest failed:") {
6286 self.state.clear_harvest_state();
6287 }
6288 if notice.message.starts_with("Can't do that:") {
6289 self.state.pending_craft_ack = None;
6290 if let Some(pending) = self.state.pending_worker_job_ack.take() {
6291 if let Some(w) = self
6292 .state
6293 .hired_workers
6294 .iter_mut()
6295 .find(|w| w.instance_id == pending.worker_instance_id)
6296 {
6297 w.route = pending.prev_route;
6298 w.mode = pending.prev_mode;
6299 w.step_label = pending.prev_step_label;
6300 w.last_error = pending.prev_last_error;
6301 }
6302 let reason = notice
6303 .message
6304 .strip_prefix("Can't do that:")
6305 .unwrap_or(¬ice.message)
6306 .trim();
6307 self.state.push_log(format!(
6308 "Route save failed for {}: {reason}",
6309 pending.worker_label
6310 ));
6311 }
6312 let reason = notice
6313 .message
6314 .strip_prefix("Can't do that:")
6315 .unwrap_or(¬ice.message)
6316 .trim();
6317 if reason.contains("already tilled") {
6318 if let Some(plot) = self.state.my_plot_under_player() {
6319 self.state.sell_plot_confirm = Some(plot.plot_id);
6320 self.state.sell_plot_armed_at = Some(Instant::now());
6321 }
6322 }
6323 }
6324 if notice.message.starts_with("Cast failed:") {
6325 self.state.cast_progress = None;
6326 }
6327 if notice.message.contains("slain the") {
6328 self.state.combat_target = None;
6329 self.state.combat_target_label = None;
6330 }
6331 if notice.message.contains("wants to trade") {
6333 if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
6334 let from_name = notice
6335 .message
6336 .split(" wants to trade")
6337 .next()
6338 .unwrap_or("Player")
6339 .to_string();
6340 self.state.social_chat.pending_trade =
6341 Some(crate::social::PendingTradeRequest {
6342 from_entity,
6343 from_name: from_name.clone(),
6344 });
6345 self.state.social_chat.push_system(format!(
6346 "{from_name} wants to trade — [Y] accept · [N] decline"
6347 ));
6348 self.state
6349 .social_chat
6350 .push_cue(crate::social::AudioCue::TradeOffer);
6351 }
6352 }
6353 if notice.message.starts_with("trade request declined") {
6354 self.state
6355 .social_chat
6356 .push_system(notice.message.clone());
6357 self.state
6358 .social_chat
6359 .push_cue(crate::social::AudioCue::TradeDeclined);
6360 }
6361 self.state.apply_interaction_notice(¬ice);
6362 self.state.push_log(notice.message.clone());
6363 }
6364 SessionEvent::ShopOpened(catalog) => {
6365 self.state.apply_shop_catalog(catalog);
6366 }
6367 SessionEvent::BankOpened(panel) => {
6368 self.state.apply_bank_panel(panel);
6369 }
6370 SessionEvent::StorageOpened(panel) => {
6371 self.state.apply_storage_panel(panel);
6372 }
6373 SessionEvent::MarketOpened(panel) => {
6374 self.state.apply_market_panel(panel);
6375 }
6376 SessionEvent::NpcTalkOpened(opened) => {
6377 self.state.show_npc_verb_menu = false;
6378 if self.state.npc_verb_target.is_none() {
6379 self.state.npc_verb_target = Some(opened.npc_id.clone());
6380 }
6381 let label = opened.npc_label.clone();
6382 let banner = if !opened.trade_allowed {
6383 Some("Trade is unavailable right now.".to_string())
6384 } else {
6385 None
6386 };
6387 self.state.show_npc_chat = true;
6388 self.state.npc_chat = Some(NpcChatState {
6389 npc_id: opened.npc_id,
6390 npc_label: opened.npc_label,
6391 lines: if opened.greeting.is_empty() {
6392 vec![]
6393 } else {
6394 vec![format!("{label}: {}", opened.greeting)]
6395 },
6396 input: String::new(),
6397 pending: opened.greeting.is_empty(),
6398 talk_depth: opened.talk_depth,
6399 trade_allowed: opened.trade_allowed,
6400 banner,
6401 });
6402 }
6403 SessionEvent::NpcTalkPending(_) => {
6404 if let Some(chat) = self.state.npc_chat.as_mut() {
6405 chat.pending = true;
6406 }
6407 }
6408 SessionEvent::NpcTalkReply(reply) => {
6409 if let Some(chat) = self.state.npc_chat.as_mut() {
6410 if chat.npc_id == reply.npc_id {
6411 chat.pending = false;
6412 if reply.trade_disabled {
6413 chat.trade_allowed = false;
6414 chat.banner = Some("Trade is unavailable right now.".to_string());
6415 }
6416 if reply.wind_down {
6417 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
6418 if chat.banner.is_none() {
6419 chat.banner =
6420 Some("They're wrapping up — keep it brief.".to_string());
6421 }
6422 }
6423 chat.lines
6424 .push(format!("{}: {}", chat.npc_label, reply.line));
6425 }
6426 }
6427 }
6428 SessionEvent::NpcTalkClosed(closed) => {
6429 if self
6430 .state
6431 .npc_chat
6432 .as_ref()
6433 .is_some_and(|c| c.npc_id == closed.npc_id)
6434 {
6435 self.state.show_npc_chat = false;
6436 self.state.npc_chat = None;
6437 }
6438 }
6439 SessionEvent::NpcTalkError(err) => {
6440 self.state.push_log(format!("Talk failed: {}", err.reason));
6441 if let Some(chat) = self.state.npc_chat.as_mut() {
6442 chat.pending = false;
6443 }
6444 }
6445 SessionEvent::UseResult(result) => {
6446 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
6449 *qty = qty.saturating_sub(1);
6450 if *qty == 0 {
6451 self.state.inventory.remove(&result.template_id);
6452 }
6453 }
6454 }
6455 SessionEvent::QuestOffer(offer) => {
6456 self.state.pending_quest_offer = Some(offer.clone());
6457 self.state.show_quest_offer = true;
6458 self.state
6459 .push_log(format!("Quest offered: {}", offer.title));
6460 }
6461 SessionEvent::QuestAccepted(notice) => {
6462 self.state.show_quest_offer = false;
6463 self.state.pending_quest_offer = None;
6464 self.state.push_log(notice.message);
6465 }
6466 SessionEvent::QuestWithdrawn(notice) => {
6467 self.state.show_quest_menu = false;
6468 self.state.quest_withdraw_confirm = false;
6469 self.state.push_log(notice.message);
6470 }
6471 SessionEvent::QuestStepCompleted(notice) => {
6472 self.state.push_log(notice.message);
6473 }
6474 SessionEvent::QuestCompleted(notice) => {
6475 self.state.push_log(notice.message);
6476 }
6477 SessionEvent::Disconnected { reason } => {
6478 self.state.clear_harvest_state();
6479 self.state.connected = false;
6480 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
6481 if let Some(r) = &self.state.disconnect_reason {
6482 self.state.push_log(format!("Disconnected: {r}"));
6483 } else {
6484 self.state.push_log("Disconnected from server");
6485 }
6486 }
6487 }
6488 Ok(())
6489 }
6490
6491 pub fn is_connected(&self) -> bool {
6492 self.state.connected
6493 }
6494
6495 pub fn close_overlays(&mut self) {
6496 self.state.show_stats = false;
6497 self.state.show_craft_menu = false;
6498 self.state.show_shop_menu = false;
6499 self.state.shop_catalog = None;
6500 self.state.show_npc_verb_menu = false;
6501 self.state.npc_verb_target = None;
6502 self.state.show_npc_chat = false;
6503 self.state.npc_chat = None;
6504 self.state.show_inventory_menu = false;
6505 self.state.show_loadout_menu = false;
6506 self.state.show_rotation_editor = false;
6507 self.state.rotation_editor.reset();
6508 self.state.show_rename_prompt = false;
6509 self.state.show_worker_rename = false;
6510 self.state.rename_buffer.clear();
6511 self.state.show_move_picker = false;
6512 self.state.move_picker = None;
6513 self.state.show_destroy_picker = false;
6514 self.state.destroy_confirm_pending = false;
6515 self.state.destroy_picker = None;
6516 self.state.show_quest_offer = false;
6517 self.state.pending_quest_offer = None;
6518 self.state.show_quest_menu = false;
6519 self.state.quest_withdraw_confirm = false;
6520 self.state.show_workers_menu = false;
6521 self.close_worker_give_picker();
6522 self.close_worker_give_target_picker();
6523 self.close_worker_take_picker();
6524 self.close_worker_teach_picker();
6525 self.state.worker_route_editor = None;
6526 self.state.claim_mode = None;
6527 self.state.relocate_mode = None;
6528 self.state.sell_plot_confirm = None;
6529 self.state.sell_plot_armed_at = None;
6530 self.close_farm_access_panel();
6531 if self.state.show_plant_menu {
6532 self.close_plant_menu();
6533 }
6534 }
6535
6536 pub fn back_on_esc(&mut self) -> bool {
6538 if self.state.social_chat.composer_open() {
6539 self.state.social_chat.close_composer();
6540 return true;
6541 }
6542 if self.state.player_verbs.open {
6543 self.state.player_verbs.close();
6544 return true;
6545 }
6546 if self.state.whisper_pouch_ui.open {
6547 self.state.whisper_pouch_ui.open = false;
6548 return true;
6549 }
6550 if self.state.trade_ui.panel.is_some() {
6551 self.state.trade_ui.close();
6553 return true;
6554 }
6555 if self.state.show_rename_prompt {
6556 self.cancel_rename_prompt();
6557 return true;
6558 }
6559 if self.state.show_worker_rename {
6560 self.cancel_worker_rename();
6561 return true;
6562 }
6563 if self.state.show_destroy_picker {
6564 if self.state.destroy_confirm_pending {
6565 self.cancel_destroy_confirm();
6566 } else {
6567 self.close_destroy_picker();
6568 }
6569 return true;
6570 }
6571 if self.state.claim_mode.is_some() {
6572 self.cancel_claim_mode();
6573 return true;
6574 }
6575 if self.state.relocate_mode.is_some() {
6576 self.cancel_relocate_mode();
6577 return true;
6578 }
6579 if self.state.show_plant_menu {
6580 self.close_plant_menu();
6581 return true;
6582 }
6583 if self.state.show_farm_access {
6584 self.close_farm_access_panel();
6585 return true;
6586 }
6587 if self.state.sell_plot_confirm.is_some() {
6588 self.state.sell_plot_confirm = None;
6589 self.state.sell_plot_armed_at = None;
6590 self.state.push_log("Sell cancelled");
6591 return true;
6592 }
6593 if self.state.show_move_picker {
6594 self.close_move_picker();
6595 return true;
6596 }
6597 if self.state.show_rotation_editor {
6598 match self.state.rotation_editor.mode {
6599 RotationEditorMode::List => {
6600 self.state.show_rotation_editor = false;
6601 self.state.rotation_editor.reset();
6602 }
6603 RotationEditorMode::EditLabel => {
6604 self.state.rotation_editor.label_buffer.clear();
6605 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
6606 }
6607 RotationEditorMode::PickAbility => {
6608 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
6609 }
6610 RotationEditorMode::EditSequence => {
6611 self.state.rotation_editor.draft = None;
6612 self.state.rotation_editor.mode = RotationEditorMode::List;
6613 }
6614 }
6615 return true;
6616 }
6617 if self.state.show_inventory_menu {
6618 self.close_inventory_menu();
6619 return true;
6620 }
6621 if self.state.show_craft_menu {
6622 self.close_craft_menu();
6623 return true;
6624 }
6625 if self.state.show_keychain_menu {
6626 self.close_keychain_menu();
6627 return true;
6628 }
6629 if self.state.show_quest_offer {
6630 self.quest_offer_decline();
6631 return true;
6632 }
6633 if self.state.show_shop_menu {
6634 return false;
6636 }
6637 if self.state.bank_panel.is_some() {
6638 return false;
6639 }
6640 if self.state.storage_panel.is_some() {
6641 return false;
6642 }
6643 if self.state.market_panel.is_some() {
6644 return false;
6645 }
6646 if self.state.show_npc_chat {
6647 return false;
6649 }
6650 if self.state.show_npc_verb_menu {
6651 self.state.show_npc_verb_menu = false;
6652 self.state.npc_verb_target = None;
6653 return true;
6654 }
6655 if self.state.show_quest_menu {
6656 if self.state.quest_withdraw_confirm {
6657 self.state.quest_withdraw_confirm = false;
6658 } else {
6659 self.state.show_quest_menu = false;
6660 }
6661 return true;
6662 }
6663 if self.state.worker_route_editor.is_some() {
6664 if self.re_at_root_sheet() {
6666 let reopen = self.state.attending_worker_instance_id.clone();
6667 self.close_worker_route_editor();
6668 if let Some(id) = reopen {
6669 if let Some(idx) = self
6670 .state
6671 .hired_workers
6672 .iter()
6673 .position(|w| w.instance_id == id)
6674 {
6675 self.state.workers_menu_index = idx;
6676 self.state.show_workers_menu = true;
6677 }
6678 }
6679 } else {
6680 self.re_sheet_back();
6681 }
6682 return true;
6683 }
6684 if self.state.show_worker_give_picker {
6685 self.close_worker_give_picker();
6686 return true;
6687 }
6688 if self.state.show_worker_give_target_picker {
6689 self.close_worker_give_target_picker();
6690 return true;
6691 }
6692 if self.state.show_worker_take_picker {
6693 self.close_worker_take_picker();
6694 return true;
6695 }
6696 if self.state.show_worker_teach_picker {
6697 self.close_worker_teach_picker();
6698 return true;
6699 }
6700 if self.state.show_workers_menu {
6701 self.close_workers_menu_ui();
6702 return true;
6703 }
6704 if self.state.show_loadout_menu {
6705 self.state.show_loadout_menu = false;
6706 return true;
6707 }
6708 if self.state.show_stats {
6709 self.state.show_stats = false;
6710 return true;
6711 }
6712 if self.state.show_equip_menu {
6713 self.state.show_equip_menu = false;
6714 return true;
6715 }
6716 false
6717 }
6718
6719 pub fn toggle_stats(&mut self) {
6720 self.state.show_stats = !self.state.show_stats;
6721 if self.state.show_stats {
6722 self.state.character_sheet_tab = CharacterSheetTab::Character;
6723 self.state.show_craft_menu = false;
6724 self.state.show_shop_menu = false;
6725 self.state.shop_catalog = None;
6726 self.state.show_inventory_menu = false;
6727 self.state.show_equip_menu = false;
6728 }
6729 }
6730
6731 pub fn toggle_equip_menu(&mut self) {
6732 self.state.show_equip_menu = !self.state.show_equip_menu;
6733 if self.state.show_equip_menu {
6734 self.state.show_stats = false;
6735 self.state.show_craft_menu = false;
6736 self.state.show_shop_menu = false;
6737 self.state.shop_catalog = None;
6738 self.state.show_inventory_menu = false;
6739 self.state.show_loadout_menu = false;
6740 }
6741 }
6742
6743 pub fn cycle_character_sheet_tab(&mut self) {
6744 if self.state.show_stats {
6745 self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
6746 }
6747 }
6748
6749 pub fn set_ledger_period_digit(&mut self, c: char) {
6750 if self.state.show_stats {
6751 if let Some(p) = LedgerPeriod::from_digit(c) {
6752 self.state.ledger_period = p;
6753 self.state.character_sheet_tab = CharacterSheetTab::Ledger;
6754 }
6755 }
6756 }
6757
6758 pub fn cycle_ledger_period(&mut self) {
6759 if self.state.show_stats
6760 && self.state.character_sheet_tab == CharacterSheetTab::Ledger
6761 {
6762 self.state.ledger_period = self.state.ledger_period.cycle();
6763 }
6764 }
6765
6766 pub fn open_inventory_menu(&mut self) {
6767 self.state.show_inventory_menu = true;
6768 self.state.show_craft_menu = false;
6769 self.state.show_shop_menu = false;
6770 self.state.shop_catalog = None;
6771 self.state.show_stats = false;
6772 self.state.show_move_picker = false;
6773 self.state.move_picker = None;
6774 self.state.show_destroy_picker = false;
6775 self.state.destroy_confirm_pending = false;
6776 self.state.destroy_picker = None;
6777 self.state.show_rename_prompt = false;
6778 self.state.rename_buffer.clear();
6779 self.state.inventory_filter_focused = false;
6780 self.state.clamp_inventory_indices();
6781 }
6782
6783 pub fn close_inventory_menu(&mut self) {
6784 self.state.show_inventory_menu = false;
6785 self.state.show_move_picker = false;
6786 self.state.move_picker = None;
6787 self.close_grant_picker();
6788 self.state.show_destroy_picker = false;
6789 self.state.destroy_confirm_pending = false;
6790 self.state.destroy_picker = None;
6791 self.state.show_rename_prompt = false;
6792 self.state.rename_buffer.clear();
6793 self.state.inventory_filter_focused = false;
6794 }
6795
6796 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
6797 let Some(row) = self.state.inventory_selected_row() else {
6798 anyhow::bail!("inventory empty");
6799 };
6800 if !self.state.row_is_renameable_container(&row) {
6801 anyhow::bail!("only storage containers can be renamed");
6802 }
6803 let current = row
6804 .stack
6805 .display_name
6806 .clone()
6807 .unwrap_or_else(|| row.stack.template_id.clone());
6808 self.state.rename_buffer = current;
6809 self.state.show_rename_prompt = true;
6810 self.state.show_worker_rename = false;
6811 self.state.show_move_picker = false;
6812 self.state.show_destroy_picker = false;
6813 self.state.destroy_confirm_pending = false;
6814 Ok(())
6815 }
6816
6817 pub fn cancel_rename_prompt(&mut self) {
6818 self.state.show_rename_prompt = false;
6819 self.state.rename_buffer.clear();
6820 }
6821
6822 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
6823 let name = self.state.rename_buffer.trim().to_string();
6824 if name.is_empty() {
6825 anyhow::bail!("name cannot be empty");
6826 }
6827 let Some(row) = self.state.inventory_selected_row() else {
6828 anyhow::bail!("inventory empty");
6829 };
6830 let Some(instance_id) = row.stack.item_instance_id else {
6831 anyhow::bail!("item has no instance id");
6832 };
6833 self.seq += 1;
6834 self.session
6835 .submit_intent(Intent::RenameContainer {
6836 entity_id: self.state.entity_id,
6837 item_instance_id: instance_id,
6838 location: row.from.clone(),
6839 name,
6840 seq: self.seq,
6841 })
6842 .await?;
6843 self.state.intents_sent += 1;
6844 self.state.show_rename_prompt = false;
6845 self.state.rename_buffer.clear();
6846 Ok(())
6847 }
6848
6849 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
6850 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
6851 anyhow::bail!("no worker selected");
6852 };
6853 self.state.rename_buffer = worker.label.clone();
6854 self.state.show_worker_rename = true;
6855 self.state.show_rename_prompt = false;
6856 Ok(())
6857 }
6858
6859 pub fn cancel_worker_rename(&mut self) {
6860 self.state.show_worker_rename = false;
6861 self.state.rename_buffer.clear();
6862 }
6863
6864 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
6865 let name = self.state.rename_buffer.trim().to_string();
6866 if name.is_empty() {
6867 anyhow::bail!("name cannot be empty");
6868 }
6869 if name.chars().count() > 32 {
6870 anyhow::bail!("name must be 1–32 characters");
6871 }
6872 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
6873 anyhow::bail!("no worker selected");
6874 };
6875 let worker_instance_id = worker.instance_id.clone();
6876 self.seq += 1;
6877 self.session
6878 .submit_intent(Intent::RenameHiredWorker {
6879 entity_id: self.state.entity_id,
6880 worker_instance_id: worker_instance_id.clone(),
6881 name: name.clone(),
6882 seq: self.seq,
6883 })
6884 .await?;
6885 self.state.intents_sent += 1;
6886 if let Some(w) = self
6887 .state
6888 .hired_workers
6889 .iter_mut()
6890 .find(|w| w.instance_id == worker_instance_id)
6891 {
6892 w.label = name.clone();
6893 }
6894 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6895 if ed.worker_instance_id == worker_instance_id {
6896 ed.worker_label = name.clone();
6897 }
6898 }
6899 self.state.show_worker_rename = false;
6900 self.state.rename_buffer.clear();
6901 self.state.push_log(format!("Renamed worker to \"{name}\""));
6902 Ok(())
6903 }
6904
6905 pub fn toggle_inventory_menu(&mut self) {
6906 if self.state.show_inventory_menu {
6907 self.close_inventory_menu();
6908 } else {
6909 self.open_inventory_menu();
6910 }
6911 }
6912
6913 pub fn inventory_menu_move(&mut self, delta: i32) {
6915 if self.state.show_grant_picker {
6916 let Some(picker) = self.state.grant_picker.as_ref() else {
6917 return;
6918 };
6919 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6920 let filter = picker.filter.clone();
6921 let n = labels.len();
6922 if n == 0 {
6923 return;
6924 }
6925 self.state.grant_picker_index = step_filtered_index(
6926 self.state.grant_picker_index,
6927 delta,
6928 n,
6929 |i| list_label_matches(&labels[i], &filter),
6930 );
6931 return;
6932 }
6933 if self.state.show_move_picker {
6934 let Some(picker) = self.state.move_picker.as_ref() else {
6935 return;
6936 };
6937 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6938 let filter = picker.filter.clone();
6939 let n = labels.len();
6940 if n == 0 {
6941 return;
6942 }
6943 self.state.move_picker_index = step_filtered_index(
6944 self.state.move_picker_index,
6945 delta,
6946 n,
6947 |i| list_label_matches(&labels[i], &filter),
6948 );
6949 self.state.clamp_move_picker_quantity();
6950 return;
6951 }
6952 let n = self.state.inventory_selectable_rows().len();
6953 if n == 0 {
6954 return;
6955 }
6956 let idx = self.state.inventory_menu_index as i32;
6957 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6958 }
6959
6960 pub fn inventory_menu_page(&mut self, pages: i32) {
6962 if self.state.show_grant_picker {
6963 let Some(picker) = self.state.grant_picker.as_ref() else {
6964 return;
6965 };
6966 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6967 let filter = picker.filter.clone();
6968 let n = labels.len();
6969 self.state.grant_picker_index = page_filtered_index(
6970 self.state.grant_picker_index,
6971 pages,
6972 n,
6973 |i| list_label_matches(&labels[i], &filter),
6974 );
6975 return;
6976 }
6977 if self.state.show_move_picker {
6978 let Some(picker) = self.state.move_picker.as_ref() else {
6979 return;
6980 };
6981 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6982 let filter = picker.filter.clone();
6983 let n = labels.len();
6984 self.state.move_picker_index = page_filtered_index(
6985 self.state.move_picker_index,
6986 pages,
6987 n,
6988 |i| list_label_matches(&labels[i], &filter),
6989 );
6990 self.state.clamp_move_picker_quantity();
6991 return;
6992 }
6993 let n = self.state.inventory_selectable_rows().len();
6994 self.state.inventory_menu_index =
6995 page_list_index(self.state.inventory_menu_index, pages, n);
6996 }
6997
6998 pub fn cycle_inventory_tab(&mut self, forward: bool) {
6999 if self.state.show_move_picker
7000 || self.state.show_grant_picker
7001 || self.state.show_destroy_picker
7002 || self.state.show_rename_prompt
7003 || self.state.inventory_filter_focused
7004 {
7005 return;
7006 }
7007 self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
7008 self.state.inventory_menu_index = 0;
7009 self.state.clamp_inventory_indices();
7010 }
7011
7012 pub fn focus_inventory_filter(&mut self) {
7013 if self.state.show_grant_picker {
7014 if let Some(p) = self.state.grant_picker.as_mut() {
7015 p.filter_focused = true;
7016 }
7017 return;
7018 }
7019 if self.state.show_move_picker {
7020 if let Some(p) = self.state.move_picker.as_mut() {
7021 p.filter_focused = true;
7022 }
7023 return;
7024 }
7025 self.state.inventory_filter_focused = true;
7026 }
7027
7028 pub fn set_inventory_filter(&mut self, filter: String) {
7029 self.state.inventory_filter = filter;
7030 self.state.inventory_menu_index = 0;
7031 self.state.clamp_inventory_indices();
7032 }
7033
7034 pub fn append_inventory_filter_char(&mut self, ch: char) {
7035 if ch.is_control() {
7036 return;
7037 }
7038 if self.state.show_grant_picker {
7039 if let Some(p) = self.state.grant_picker.as_mut() {
7040 if p.filter_focused {
7041 p.filter.push(ch);
7042 self.state.grant_picker_index = 0;
7043 }
7044 }
7045 return;
7046 }
7047 if self.state.show_move_picker {
7048 if let Some(p) = self.state.move_picker.as_mut() {
7049 if p.filter_focused {
7050 p.filter.push(ch);
7051 self.state.move_picker_index = 0;
7052 self.state.clamp_move_picker_quantity();
7053 }
7054 }
7055 return;
7056 }
7057 if !self.state.inventory_filter_focused {
7058 return;
7059 }
7060 self.state.inventory_filter.push(ch);
7061 self.state.inventory_menu_index = 0;
7062 self.state.clamp_inventory_indices();
7063 }
7064
7065 pub fn inventory_filter_backspace(&mut self) {
7066 if self.state.show_grant_picker {
7067 if let Some(p) = self.state.grant_picker.as_mut() {
7068 if p.filter_focused {
7069 p.filter.pop();
7070 self.state.grant_picker_index = 0;
7071 }
7072 }
7073 return;
7074 }
7075 if self.state.show_move_picker {
7076 if let Some(p) = self.state.move_picker.as_mut() {
7077 if p.filter_focused {
7078 p.filter.pop();
7079 self.state.move_picker_index = 0;
7080 self.state.clamp_move_picker_quantity();
7081 }
7082 }
7083 return;
7084 }
7085 if !self.state.inventory_filter_focused {
7086 return;
7087 }
7088 self.state.inventory_filter.pop();
7089 self.state.inventory_menu_index = 0;
7090 self.state.clamp_inventory_indices();
7091 }
7092
7093 pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
7095 if self.state.show_grant_picker {
7096 if let Some(p) = self.state.grant_picker.as_mut() {
7097 if p.filter_focused {
7098 if !p.filter.is_empty() {
7099 p.filter.clear();
7100 self.state.grant_picker_index = 0;
7101 } else {
7102 p.filter_focused = false;
7103 }
7104 return true;
7105 }
7106 if !p.filter.is_empty() {
7107 p.filter.clear();
7108 self.state.grant_picker_index = 0;
7109 return true;
7110 }
7111 }
7112 return false;
7113 }
7114 if self.state.show_move_picker {
7115 if let Some(p) = self.state.move_picker.as_mut() {
7116 if p.filter_focused {
7117 if !p.filter.is_empty() {
7118 p.filter.clear();
7119 self.state.move_picker_index = 0;
7120 self.state.clamp_move_picker_quantity();
7121 } else {
7122 p.filter_focused = false;
7123 }
7124 return true;
7125 }
7126 if !p.filter.is_empty() {
7127 p.filter.clear();
7128 self.state.move_picker_index = 0;
7129 self.state.clamp_move_picker_quantity();
7130 return true;
7131 }
7132 }
7133 return false;
7134 }
7135 if self.state.inventory_filter_focused {
7136 if !self.state.inventory_filter.is_empty() {
7137 self.state.inventory_filter.clear();
7138 self.state.inventory_menu_index = 0;
7139 self.state.clamp_inventory_indices();
7140 } else {
7141 self.state.inventory_filter_focused = false;
7142 }
7143 return true;
7144 }
7145 if !self.state.inventory_filter.is_empty() {
7146 self.state.inventory_filter.clear();
7147 self.state.inventory_menu_index = 0;
7148 self.state.clamp_inventory_indices();
7149 return true;
7150 }
7151 false
7152 }
7153
7154 pub fn craft_menu_page(&mut self, pages: i32) {
7155 let n = self.state.blueprints.len();
7156 self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
7157 self.state.clamp_craft_batch_quantity();
7158 }
7159
7160 pub fn shop_menu_page(&mut self, pages: i32) {
7161 let n = self.state.shop_list_len();
7162 self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
7163 self.state.clamp_shop_quantity();
7164 }
7165
7166 pub fn workers_menu_page(&mut self, pages: i32) {
7167 let n = self.state.hired_workers.len();
7168 self.state.workers_menu_index =
7169 page_list_index(self.state.workers_menu_index, pages, n);
7170 }
7171
7172 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
7177 if self.state.show_destroy_picker {
7178 if self.state.destroy_confirm_pending {
7179 return self.confirm_destroy_item().await;
7180 }
7181 return self.request_destroy_confirm();
7182 }
7183 if self.state.show_grant_picker {
7184 return self.confirm_grant_picker().await;
7185 }
7186 if self.state.show_move_picker {
7187 return self.confirm_move_picker().await;
7188 }
7189 let Some(row) = self.state.inventory_selected_row() else {
7190 anyhow::bail!("inventory empty");
7191 };
7192 if row.is_equip_shell {
7193 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
7194 anyhow::bail!("not a worn item");
7195 };
7196 return self.equip_worn(slot, None).await;
7197 }
7198 if row.is_chest_shell {
7199 return self.open_chest_pickup_picker();
7200 }
7201 let template_id = row.stack.template_id.clone();
7202 let instance_id = row.stack.item_instance_id;
7203 let category = self.state.inventory_item_category(&template_id);
7204 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7205
7206 if category == Some("weapon") {
7207 return self.equip_mainhand(Some(template_id)).await;
7208 }
7209 if category == Some("lodging") && on_person {
7210 if let Some(inst) = instance_id {
7211 return self.place_container(inst).await;
7212 }
7213 }
7214 if (category == Some("container") || category == Some("armor")) && on_person {
7215 if let Some(inst) = instance_id {
7216 let world_placeable = row.stack.world_placeable == Some(true)
7217 || template_id.contains("chest");
7218 if world_placeable {
7219 return self.place_container(inst).await;
7220 }
7221 if let Some(slot) = guess_body_slot(&template_id) {
7225 return self.equip_worn(slot, Some(inst)).await;
7226 }
7227 }
7228 }
7229 self.open_move_picker()
7233 }
7234
7235 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
7237 let Some(row) = self.state.inventory_selected_row() else {
7238 anyhow::bail!("inventory empty");
7239 };
7240 if row.from != flatland_protocol::InventoryLocation::Root {
7241 anyhow::bail!("select a consumable on your person");
7242 }
7243 if GameState::stack_is_item_grant(&row.stack) {
7244 return self.open_grant_target_picker();
7245 }
7246 if GameState::is_property_deed_template(&row.stack.template_id) {
7247 return self.open_move_picker();
7248 }
7249 let category = self
7250 .state
7251 .inventory_item_category(&row.stack.template_id);
7252 if category != Some("consumable") {
7253 anyhow::bail!("selected item is not consumable");
7254 }
7255 self.use_item(&row.stack.template_id).await
7256 }
7257
7258 pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
7260 let Some(row) = self.state.inventory_selected_row() else {
7261 anyhow::bail!("inventory empty");
7262 };
7263 if row.from != flatland_protocol::InventoryLocation::Root {
7264 anyhow::bail!("select a grant item on your person");
7265 }
7266 if !GameState::stack_is_item_grant(&row.stack) {
7267 anyhow::bail!("selected item does not grant onto gear");
7268 }
7269 let Some(grant_instance_id) = row.stack.item_instance_id else {
7270 anyhow::bail!("grant has no instance id");
7271 };
7272 let effect_id = GameState::grant_effect_id(&row.stack)
7273 .unwrap_or("?")
7274 .to_string();
7275 let mode = GameState::grant_mode(&row.stack).to_string();
7276 let options = self.state.grant_target_options(&row.stack);
7277 if options.is_empty() {
7278 anyhow::bail!("no valid gear to apply {effect_id} to");
7279 }
7280 let grant_label = row
7281 .stack
7282 .display_name
7283 .clone()
7284 .unwrap_or_else(|| row.stack.template_id.clone());
7285 self.state.show_grant_picker = true;
7286 self.state.grant_picker_index = 0;
7287 self.state.grant_picker = Some(GrantTargetPicker {
7288 grant_instance_id,
7289 grant_label,
7290 effect_id,
7291 mode,
7292 options,
7293 filter: String::new(),
7294 filter_focused: false,
7295 });
7296 Ok(())
7297 }
7298
7299 pub fn close_grant_picker(&mut self) {
7300 self.state.show_grant_picker = false;
7301 self.state.grant_picker = None;
7302 self.state.grant_picker_index = 0;
7303 }
7304
7305 pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
7306 let Some(picker) = self.state.grant_picker.clone() else {
7307 self.close_grant_picker();
7308 return Ok(());
7309 };
7310 let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
7311 self.close_grant_picker();
7312 return Ok(());
7313 };
7314 self.close_grant_picker();
7315 self.use_grant(picker.grant_instance_id, opt.target_instance_id)
7316 .await?;
7317 self.state.push_log(format!(
7318 "Applying {} onto {}…",
7319 picker.effect_id, opt.label
7320 ));
7321 Ok(())
7322 }
7323
7324 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
7328 let Some(row) = self.state.inventory_selected_row() else {
7329 anyhow::bail!("inventory empty");
7330 };
7331 if row.is_equip_shell {
7332 anyhow::bail!("this is a worn bag — press Enter to unequip it");
7333 }
7334 if row.is_chest_shell {
7335 return self.open_chest_pickup_picker();
7336 }
7337 let Some(instance_id) = row.stack.item_instance_id else {
7338 anyhow::bail!("item has no instance id");
7339 };
7340 let mut options = self.state.move_destinations_for(
7341 &row.from,
7342 row.from_parent_instance_id,
7343 row.stack.item_instance_id,
7344 &row.stack.template_id,
7345 );
7346 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7347 let category = self.state.inventory_item_category(&row.stack.template_id);
7348 if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
7349 if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
7350 options.insert(
7351 0,
7352 MoveOption {
7353 label: "Sell plot to crown…".into(),
7354 kind: MoveOptionKind::SellPlotToCrown { plot_id },
7355 },
7356 );
7357 }
7358 }
7359 if on_person && category == Some("consumable") {
7360 if GameState::stack_is_item_grant(&row.stack) {
7361 options.insert(
7362 0,
7363 MoveOption {
7364 label: "Apply onto gear…".into(),
7365 kind: MoveOptionKind::GrantApply,
7366 },
7367 );
7368 } else {
7369 options.insert(
7370 0,
7371 MoveOption {
7372 label: "Use (eat / drink)".into(),
7373 kind: MoveOptionKind::Use,
7374 },
7375 );
7376 }
7377 }
7378 let item_label = row
7379 .stack
7380 .display_name
7381 .clone()
7382 .unwrap_or_else(|| row.stack.template_id.clone());
7383 let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
7386 self.state.move_picker = Some(MovePicker {
7387 item_instance_id: instance_id,
7388 from: row.from,
7389 item_label,
7390 template_id: row.stack.template_id.clone(),
7391 stack_quantity: row.stack.quantity,
7392 quantity: initial_qty.max(1),
7393 options,
7394 filter: String::new(),
7395 filter_focused: false,
7396 });
7397 self.state.move_picker_index = 0;
7398 self.state.show_move_picker = true;
7399 self.state.show_destroy_picker = false;
7400 self.state.destroy_confirm_pending = false;
7401 self.state.destroy_picker = None;
7402 self.state.clamp_move_picker_quantity();
7403 Ok(())
7404 }
7405
7406 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
7408 let Some(row) = self.state.inventory_selected_row() else {
7409 anyhow::bail!("inventory empty");
7410 };
7411 if !row.is_chest_shell {
7412 anyhow::bail!("not a placed chest");
7413 }
7414 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
7415 anyhow::bail!("not a placed chest");
7416 };
7417 let Some(instance_id) = row.stack.item_instance_id else {
7418 anyhow::bail!("chest has no instance id");
7419 };
7420 let chest = self
7421 .state
7422 .placed_containers
7423 .iter()
7424 .find(|c| c.id == *container_id)
7425 .cloned()
7426 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
7427 let (px, py) = self.state.player_position();
7428 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
7429 anyhow::bail!("too far from {}", chest.display_name);
7430 }
7431 if chest.locked && !chest.accessible {
7432 anyhow::bail!(
7433 "need the matching key for {} before picking it up",
7434 chest.display_name
7435 );
7436 }
7437 let options = self.state.chest_pickup_destinations(container_id);
7438 let item_label = row
7439 .stack
7440 .display_name
7441 .clone()
7442 .unwrap_or_else(|| row.stack.template_id.clone());
7443 self.state.move_picker = Some(MovePicker {
7444 item_instance_id: instance_id,
7445 from: row.from.clone(),
7446 item_label,
7447 template_id: row.stack.template_id.clone(),
7448 stack_quantity: 1,
7449 quantity: 1,
7450 options,
7451 filter: String::new(),
7452 filter_focused: false,
7453 });
7454 self.state.move_picker_index = 0;
7455 self.state.show_move_picker = true;
7456 self.state.show_destroy_picker = false;
7457 self.state.destroy_confirm_pending = false;
7458 self.state.destroy_picker = None;
7459 Ok(())
7460 }
7461
7462 pub fn close_move_picker(&mut self) {
7463 self.state.show_move_picker = false;
7464 self.state.move_picker = None;
7465 }
7466
7467 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
7468 self.state.move_picker_adjust_quantity(delta);
7469 }
7470
7471 pub fn move_picker_set_quantity_max(&mut self) {
7472 self.state.move_picker_set_quantity_max();
7473 }
7474
7475 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
7476 self.state.destroy_picker_adjust_quantity(delta);
7477 }
7478
7479 pub fn destroy_picker_set_quantity_max(&mut self) {
7480 self.state.destroy_picker_set_quantity_max();
7481 }
7482
7483 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
7484 let Some(picker) = self.state.move_picker.clone() else {
7485 self.close_move_picker();
7486 return Ok(());
7487 };
7488 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
7489 self.close_move_picker();
7490 return Ok(());
7491 };
7492 match option.kind {
7493 MoveOptionKind::Cancel => {
7494 self.close_move_picker();
7495 }
7496 MoveOptionKind::Use => {
7497 self.close_move_picker();
7498 self.use_item(&picker.template_id).await?;
7499 }
7500 MoveOptionKind::GrantApply => {
7501 self.close_move_picker();
7502 self.open_grant_target_picker()?;
7503 }
7504 MoveOptionKind::SellPlotToCrown { plot_id } => {
7505 self.close_move_picker();
7506 self.confirm_sell_plot_to_crown(plot_id).await?;
7507 }
7508 MoveOptionKind::RelocatePlaced { container_id } => {
7509 self.close_move_picker();
7510 self.state.show_inventory_menu = false;
7511 self.begin_relocate_container(&container_id)?;
7512 }
7513 MoveOptionKind::Drop => {
7514 self.close_move_picker();
7515 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
7516 if self.state.deed_bound(&stack) {
7517 anyhow::bail!(
7518 "cannot drop a property deed — store it or trade it to another player"
7519 );
7520 }
7521 if self.state.key_drop_blocked(&stack) {
7522 anyhow::bail!("cannot drop the key while its chest is locked");
7523 }
7524 }
7525 self.drop_item(picker.item_instance_id, picker.from).await?;
7526 self.state
7527 .push_log(format!("Dropped {}", picker.item_label));
7528 }
7529 MoveOptionKind::PickupPlaced {
7530 container_id,
7531 nest_location,
7532 nest_parent_instance_id,
7533 } => {
7534 self.close_move_picker();
7535 self.pickup_container(container_id.clone()).await?;
7536 let nest_into_bag = nest_parent_instance_id.is_some()
7537 || !matches!(
7538 nest_location,
7539 flatland_protocol::InventoryLocation::Root
7540 );
7541 if nest_into_bag {
7542 self.move_item(
7543 picker.item_instance_id,
7544 flatland_protocol::InventoryLocation::Root,
7545 nest_location,
7546 nest_parent_instance_id,
7547 None,
7548 )
7549 .await?;
7550 self.state
7551 .push_log(format!("Picked up {} into bag", picker.item_label));
7552 } else {
7553 self.state
7554 .push_log(format!("Picked up {}", picker.item_label));
7555 }
7556 }
7557 MoveOptionKind::Move {
7558 location,
7559 parent_instance_id,
7560 } => {
7561 self.close_move_picker();
7562 let qty = if picker.quantity >= picker.stack_quantity {
7563 None
7564 } else {
7565 Some(picker.quantity)
7566 };
7567 self.move_item(
7568 picker.item_instance_id,
7569 picker.from,
7570 location,
7571 parent_instance_id,
7572 qty,
7573 )
7574 .await?;
7575 let moved = qty.unwrap_or(picker.stack_quantity);
7576 if moved >= picker.stack_quantity {
7577 self.state.push_log(format!("Moved {}", picker.item_label));
7578 } else {
7579 self.state.push_log(format!(
7580 "Moved {} ×{} of {}",
7581 picker.item_label, moved, picker.stack_quantity
7582 ));
7583 }
7584 }
7585 }
7586 Ok(())
7587 }
7588
7589 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
7591 let Some(row) = self.state.inventory_selected_row() else {
7592 anyhow::bail!("inventory empty");
7593 };
7594 if row.is_equip_shell {
7595 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
7596 }
7597 if row.is_chest_shell {
7598 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
7599 }
7600 let Some(inst) = row.stack.item_instance_id else {
7601 anyhow::bail!("item has no instance id");
7602 };
7603 if self.state.deed_bound(&row.stack) {
7604 anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
7605 }
7606 if self.state.key_drop_blocked(&row.stack) {
7607 anyhow::bail!("cannot drop the key while its chest is locked");
7608 }
7609 let label = row
7610 .stack
7611 .display_name
7612 .clone()
7613 .unwrap_or_else(|| row.stack.template_id.clone());
7614 self.drop_item(inst, row.from).await?;
7615 self.state.push_log(format!("Dropped {label}"));
7616 Ok(())
7617 }
7618
7619 pub async fn drop_item(
7620 &mut self,
7621 item_instance_id: uuid::Uuid,
7622 from: flatland_protocol::InventoryLocation,
7623 ) -> anyhow::Result<()> {
7624 self.seq += 1;
7625 self.session
7626 .submit_intent(Intent::DropItem {
7627 entity_id: self.state.entity_id,
7628 item_instance_id,
7629 from,
7630 seq: self.seq,
7631 })
7632 .await?;
7633 self.state.intents_sent += 1;
7634 Ok(())
7635 }
7636
7637 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
7639 let Some(row) = self.state.inventory_selected_row() else {
7640 anyhow::bail!("inventory empty");
7641 };
7642 if row.is_equip_shell {
7643 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
7644 }
7645 if row.is_chest_shell {
7646 anyhow::bail!("can't destroy a placed chest from the inventory list");
7647 }
7648 let Some(instance_id) = row.stack.item_instance_id else {
7649 anyhow::bail!("item has no instance id");
7650 };
7651 if self.state.deed_bound(&row.stack) {
7652 anyhow::bail!(
7653 "cannot destroy a property deed — store it or trade it to another player"
7654 );
7655 }
7656 if self.state.key_drop_blocked(&row.stack) {
7657 anyhow::bail!("cannot destroy the key while its chest is locked");
7658 }
7659 let item_label = row
7660 .stack
7661 .display_name
7662 .clone()
7663 .unwrap_or_else(|| row.stack.template_id.clone());
7664 self.state.destroy_picker = Some(DestroyPicker {
7665 item_instance_id: instance_id,
7666 from: row.from,
7667 item_label,
7668 stack_quantity: row.stack.quantity,
7669 quantity: row.stack.quantity,
7670 });
7671 self.state.destroy_confirm_pending = false;
7672 self.state.show_destroy_picker = true;
7673 self.state.show_move_picker = false;
7674 self.state.move_picker = None;
7675 Ok(())
7676 }
7677
7678 pub fn close_destroy_picker(&mut self) {
7679 self.state.show_destroy_picker = false;
7680 self.state.destroy_confirm_pending = false;
7681 self.state.destroy_picker = None;
7682 }
7683
7684 pub fn cancel_destroy_confirm(&mut self) {
7685 self.state.destroy_confirm_pending = false;
7686 }
7687
7688 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
7689 if self.state.destroy_picker.is_none() {
7690 self.close_destroy_picker();
7691 return Ok(());
7692 }
7693 self.state.destroy_confirm_pending = true;
7694 Ok(())
7695 }
7696
7697 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
7698 let Some(picker) = self.state.destroy_picker.clone() else {
7699 self.close_destroy_picker();
7700 return Ok(());
7701 };
7702 let qty = if picker.quantity >= picker.stack_quantity {
7703 None
7704 } else {
7705 Some(picker.quantity)
7706 };
7707 self.destroy_item(picker.item_instance_id, picker.from, qty)
7708 .await?;
7709 let destroyed = qty.unwrap_or(picker.stack_quantity);
7710 if destroyed >= picker.stack_quantity {
7711 self.state
7712 .push_log(format!("Destroyed {}", picker.item_label));
7713 } else {
7714 self.state.push_log(format!(
7715 "Destroyed {} ×{} of {}",
7716 picker.item_label, destroyed, picker.stack_quantity
7717 ));
7718 }
7719 self.close_destroy_picker();
7720 Ok(())
7721 }
7722
7723 pub async fn destroy_item(
7724 &mut self,
7725 item_instance_id: uuid::Uuid,
7726 from: flatland_protocol::InventoryLocation,
7727 quantity: Option<u32>,
7728 ) -> anyhow::Result<()> {
7729 self.seq += 1;
7730 self.session
7731 .submit_intent(Intent::DestroyItem {
7732 entity_id: self.state.entity_id,
7733 item_instance_id,
7734 from,
7735 quantity,
7736 seq: self.seq,
7737 })
7738 .await?;
7739 self.state.intents_sent += 1;
7740 Ok(())
7741 }
7742
7743 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
7745 if let Some(row) = self.state.inventory_selected_row() {
7746 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
7747 return self.toggle_placed_chest_lock(container_id).await;
7748 }
7749 }
7750 self.toggle_nearby_chest_lock().await
7751 }
7752
7753 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
7754 let chest = self
7755 .state
7756 .placed_containers
7757 .iter()
7758 .find(|c| c.id == container_id)
7759 .cloned()
7760 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
7761 let (px, py) = self.state.player_position();
7762 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
7763 anyhow::bail!("too far from {}", chest.display_name);
7764 }
7765 if !chest.accessible && chest.locked {
7766 anyhow::bail!(
7767 "need the matching key for {} (each crafted chest has its own key)",
7768 chest.display_name
7769 );
7770 }
7771 let lock = !chest.locked;
7772 self.set_container_locked(
7773 flatland_protocol::InventoryLocation::Placed {
7774 container_id: chest.id.clone(),
7775 },
7776 lock,
7777 )
7778 .await?;
7779 self.state.push_log(if lock {
7780 format!("Locked {}", chest.display_name)
7781 } else {
7782 format!("Unlocked {}", chest.display_name)
7783 });
7784 Ok(())
7785 }
7786
7787 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
7789 let chest = self
7790 .state
7791 .nearest_placed_container(CONTAINER_RANGE_M)
7792 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
7793 self.toggle_placed_chest_lock(&chest.id).await
7794 }
7795
7796 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
7797 self.equip_mainhand(None).await
7798 }
7799
7800 pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
7801 if !self.state.is_alive() {
7802 anyhow::bail!("you are dead");
7803 }
7804 self.seq += 1;
7805 self.session
7806 .submit_intent(Intent::EquipOffhand {
7807 entity_id: self.state.entity_id,
7808 template_id,
7809 instance_id: None,
7810 seq: self.seq,
7811 })
7812 .await?;
7813 self.state.intents_sent += 1;
7814 Ok(())
7815 }
7816
7817 pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
7818 self.equip_offhand(None).await
7819 }
7820
7821 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
7822 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
7823 for slot in slots {
7824 self.equip_worn(slot, None).await?;
7825 }
7826 Ok(())
7827 }
7828
7829 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
7830 let (px, py) = self.state.player_position();
7831 let nearest = self
7832 .state
7833 .placed_containers
7834 .iter()
7835 .min_by(|a, b| {
7836 let da = (a.x - px).hypot(a.y - py);
7837 let db = (b.x - px).hypot(b.y - py);
7838 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
7839 })
7840 .cloned();
7841 let Some(chest) = nearest else {
7842 anyhow::bail!("no chest nearby");
7843 };
7844 if (chest.x - px).hypot(chest.y - py) > 2.0 {
7845 anyhow::bail!("too far from chest");
7846 }
7847 self.pickup_container(chest.id).await
7848 }
7849
7850 pub async fn equip_worn(
7851 &mut self,
7852 slot: BodySlot,
7853 instance_id: Option<uuid::Uuid>,
7854 ) -> anyhow::Result<()> {
7855 self.seq += 1;
7856 self.session
7857 .submit_intent(Intent::EquipWorn {
7858 entity_id: self.state.entity_id,
7859 slot,
7860 instance_id,
7861 seq: self.seq,
7862 })
7863 .await?;
7864 self.state.intents_sent += 1;
7865 Ok(())
7866 }
7867
7868 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
7869 self.seq += 1;
7870 self.session
7871 .submit_intent(Intent::PlaceContainer {
7872 entity_id: self.state.entity_id,
7873 item_instance_id,
7874 seq: self.seq,
7875 })
7876 .await?;
7877 self.state.intents_sent += 1;
7878 Ok(())
7879 }
7880
7881 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
7882 self.seq += 1;
7883 self.session
7884 .submit_intent(Intent::PickupContainer {
7885 entity_id: self.state.entity_id,
7886 container_id,
7887 seq: self.seq,
7888 })
7889 .await?;
7890 self.state.intents_sent += 1;
7891 Ok(())
7892 }
7893
7894 pub async fn move_item(
7895 &mut self,
7896 item_instance_id: uuid::Uuid,
7897 from: flatland_protocol::InventoryLocation,
7898 to: flatland_protocol::InventoryLocation,
7899 to_parent_instance_id: Option<uuid::Uuid>,
7900 quantity: Option<u32>,
7901 ) -> anyhow::Result<()> {
7902 self.seq += 1;
7903 self.session
7904 .submit_intent(Intent::MoveItem {
7905 entity_id: self.state.entity_id,
7906 item_instance_id,
7907 from,
7908 to,
7909 to_parent_instance_id,
7910 quantity,
7911 seq: self.seq,
7912 })
7913 .await?;
7914 self.state.intents_sent += 1;
7915 Ok(())
7916 }
7917
7918 pub async fn set_container_locked(
7919 &mut self,
7920 location: flatland_protocol::InventoryLocation,
7921 locked: bool,
7922 ) -> anyhow::Result<()> {
7923 self.seq += 1;
7924 self.session
7925 .submit_intent(Intent::SetContainerLocked {
7926 entity_id: self.state.entity_id,
7927 location,
7928 locked,
7929 seq: self.seq,
7930 })
7931 .await?;
7932 self.state.intents_sent += 1;
7933 Ok(())
7934 }
7935
7936 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
7937 if !self.state.is_alive() {
7938 anyhow::bail!("you are dead");
7939 }
7940 self.seq += 1;
7941 self.session
7942 .submit_intent(Intent::Use {
7943 entity_id: self.state.entity_id,
7944 template_id: template_id.to_string(),
7945 seq: self.seq,
7946 })
7947 .await?;
7948 self.state.intents_sent += 1;
7949 Ok(())
7950 }
7951
7952 pub async fn use_grant(
7954 &mut self,
7955 grant_instance_id: uuid::Uuid,
7956 target_instance_id: uuid::Uuid,
7957 ) -> anyhow::Result<()> {
7958 if !self.state.is_alive() {
7959 anyhow::bail!("you are dead");
7960 }
7961 self.seq += 1;
7962 self.session
7963 .submit_intent(Intent::UseGrant {
7964 entity_id: self.state.entity_id,
7965 grant_instance_id,
7966 target_instance_id,
7967 seq: self.seq,
7968 })
7969 .await?;
7970 self.state.intents_sent += 1;
7971 Ok(())
7972 }
7973
7974 pub fn open_craft_menu(&mut self) {
7975 self.state.show_craft_menu = true;
7976 self.state.show_shop_menu = false;
7977 self.state.shop_catalog = None;
7978 self.state.show_stats = false;
7979 self.state.show_inventory_menu = false;
7980 if self.state.blueprints.is_empty() {
7981 self.state.craft_menu_index = 0;
7982 self.state.craft_batch_quantity = 1;
7983 return;
7984 }
7985 self.state.craft_menu_index = self
7986 .state
7987 .craft_menu_index
7988 .min(self.state.blueprints.len() - 1);
7989 if let Some(idx) = self
7990 .state
7991 .blueprints
7992 .iter()
7993 .position(|bp| self.state.can_craft_blueprint(bp))
7994 {
7995 self.state.craft_menu_index = idx;
7996 }
7997 self.state.clamp_craft_batch_quantity();
7998 }
7999
8000 pub fn close_craft_menu(&mut self) {
8001 self.state.show_craft_menu = false;
8002 }
8003
8004 pub fn toggle_keychain_menu(&mut self) {
8005 if self.state.show_keychain_menu {
8006 self.close_keychain_menu();
8007 } else {
8008 self.state.show_keychain_menu = true;
8009 self.state.show_craft_menu = false;
8010 self.state.show_shop_menu = false;
8011 self.state.show_inventory_menu = false;
8012 let n = self.state.keychain_entries().len();
8013 if n == 0 {
8014 self.state.keychain_menu_index = 0;
8015 } else {
8016 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
8017 }
8018 }
8019 }
8020
8021 pub fn close_keychain_menu(&mut self) {
8022 self.state.show_keychain_menu = false;
8023 }
8024
8025 pub fn keychain_menu_move(&mut self, delta: i32) {
8026 let n = self.state.keychain_entries().len();
8027 if n == 0 {
8028 self.state.keychain_menu_index = 0;
8029 return;
8030 }
8031 let idx = self.state.keychain_menu_index as i32 + delta;
8032 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
8033 }
8034
8035 pub fn keychain_menu_page(&mut self, pages: i32) {
8036 let n = self.state.keychain_entries().len();
8037 self.state.keychain_menu_index =
8038 page_list_index(self.state.keychain_menu_index, pages, n);
8039 }
8040
8041 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
8042 if !self.state.is_alive() {
8043 anyhow::bail!("you are dead");
8044 }
8045 let entries = self.state.keychain_entries();
8046 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
8047 anyhow::bail!("nothing selected");
8048 };
8049 let Some(instance_id) = entry.stack.item_instance_id else {
8050 anyhow::bail!("key has no instance id");
8051 };
8052 if entry.stowed {
8053 self.move_item(
8054 instance_id,
8055 flatland_protocol::InventoryLocation::Keychain,
8056 flatland_protocol::InventoryLocation::Root,
8057 None,
8058 Some(1),
8059 )
8060 .await
8061 } else {
8062 self.move_item(
8063 instance_id,
8064 flatland_protocol::InventoryLocation::Root,
8065 flatland_protocol::InventoryLocation::Keychain,
8066 None,
8067 Some(1),
8068 )
8069 .await
8070 }
8071 }
8072
8073 pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
8074 let npc_id = self
8075 .state
8076 .shop_catalog
8077 .as_ref()
8078 .map(|c| c.npc_id.clone());
8079 self.state.show_shop_menu = false;
8080 self.state.shop_catalog = None;
8081 self.state.clear_shop_trade_log();
8082 if let Some(npc_id) = npc_id {
8083 self.seq += 1;
8084 self.session
8085 .submit_intent(Intent::ShopClose {
8086 entity_id: self.state.entity_id,
8087 npc_id,
8088 seq: self.seq,
8089 })
8090 .await?;
8091 self.state.intents_sent += 1;
8092 }
8093 Ok(())
8094 }
8095
8096 pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8097 let Some(panel) = self.state.bank_panel.clone() else {
8098 return Ok(());
8099 };
8100 self.seq += 1;
8101 self.session
8102 .submit_intent(Intent::BankDeposit {
8103 entity_id: self.state.entity_id,
8104 npc_id: panel.npc_id,
8105 amount_copper,
8106 seq: self.seq,
8107 })
8108 .await?;
8109 self.state.intents_sent += 1;
8110 Ok(())
8111 }
8112
8113 pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8114 let Some(panel) = self.state.bank_panel.clone() else {
8115 return Ok(());
8116 };
8117 self.seq += 1;
8118 self.session
8119 .submit_intent(Intent::BankWithdraw {
8120 entity_id: self.state.entity_id,
8121 npc_id: panel.npc_id,
8122 amount_copper,
8123 seq: self.seq,
8124 })
8125 .await?;
8126 self.state.intents_sent += 1;
8127 Ok(())
8128 }
8129
8130 pub async fn bank_transfer(
8131 &mut self,
8132 to_character_id: Option<uuid::Uuid>,
8133 to_name: String,
8134 amount_copper: u64,
8135 ) -> anyhow::Result<()> {
8136 let Some(panel) = self.state.bank_panel.clone() else {
8137 return Ok(());
8138 };
8139 self.seq += 1;
8140 self.session
8141 .submit_intent(Intent::BankTransfer {
8142 entity_id: self.state.entity_id,
8143 npc_id: panel.npc_id,
8144 to_character_id,
8145 to_name,
8146 amount_copper,
8147 seq: self.seq,
8148 })
8149 .await?;
8150 self.state.intents_sent += 1;
8151 Ok(())
8152 }
8153
8154 pub fn bank_menu_move(&mut self, delta: i32) {
8155 let n = self.state.bank_menu_options().len();
8156 if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
8157 return;
8158 }
8159 let idx = self.state.bank_menu_index as i32;
8160 self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8161 }
8162
8163 pub fn storage_menu_move(&mut self, delta: i32) {
8164 let n = self.state.storage_menu_options().len();
8165 if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
8166 return;
8167 }
8168 let idx = self.state.storage_menu_index as i32;
8169 self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8170 }
8171
8172 pub fn storage_pick_move(&mut self, delta: i32) {
8173 let n = match &self.state.storage_ui_mode {
8174 StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
8175 StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
8176 self.state.storage_vault_options().len()
8177 }
8178 StorageUiMode::Menu
8179 | StorageUiMode::StoreAmount { .. }
8180 | StorageUiMode::TakeAmount { .. }
8181 | StorageUiMode::ShipAmount { .. } => 0,
8182 };
8183 if n == 0 {
8184 return;
8185 }
8186 match &mut self.state.storage_ui_mode {
8187 StorageUiMode::StorePick { index }
8188 | StorageUiMode::TakePick { index }
8189 | StorageUiMode::ShipPick { index, .. } => {
8190 *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8191 }
8192 StorageUiMode::Menu
8193 | StorageUiMode::StoreAmount { .. }
8194 | StorageUiMode::TakeAmount { .. }
8195 | StorageUiMode::ShipAmount { .. } => {}
8196 }
8197 }
8198
8199 pub fn storage_ui_back(&mut self) {
8200 self.state.storage_ui_mode = match &self.state.storage_ui_mode {
8201 StorageUiMode::StoreAmount { pick_index, .. } => StorageUiMode::StorePick {
8202 index: *pick_index,
8203 },
8204 StorageUiMode::TakeAmount { pick_index, .. } => StorageUiMode::TakePick {
8205 index: *pick_index,
8206 },
8207 StorageUiMode::ShipAmount {
8208 dest_building_id,
8209 dest_label,
8210 pick_index,
8211 ..
8212 } => StorageUiMode::ShipPick {
8213 dest_building_id: dest_building_id.clone(),
8214 dest_label: dest_label.clone(),
8215 index: *pick_index,
8216 },
8217 StorageUiMode::StorePick { .. }
8218 | StorageUiMode::TakePick { .. }
8219 | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
8220 StorageUiMode::Menu => StorageUiMode::Menu,
8221 };
8222 }
8223
8224 pub fn storage_amount_append_char(&mut self, c: char) {
8225 match &mut self.state.storage_ui_mode {
8226 StorageUiMode::StoreAmount { input, .. }
8227 | StorageUiMode::TakeAmount { input, .. }
8228 | StorageUiMode::ShipAmount { input, .. } => {
8229 if c.is_ascii_digit() && input.len() < 8 {
8230 input.push(c);
8231 }
8232 }
8233 _ => {}
8234 }
8235 }
8236
8237 pub fn storage_amount_backspace(&mut self) {
8238 match &mut self.state.storage_ui_mode {
8239 StorageUiMode::StoreAmount { input, .. }
8240 | StorageUiMode::TakeAmount { input, .. }
8241 | StorageUiMode::ShipAmount { input, .. } => {
8242 input.pop();
8243 }
8244 _ => {}
8245 }
8246 }
8247
8248 pub fn storage_ui_typing(&self) -> bool {
8249 matches!(
8250 self.state.storage_ui_mode,
8251 StorageUiMode::StoreAmount { .. }
8252 | StorageUiMode::TakeAmount { .. }
8253 | StorageUiMode::ShipAmount { .. }
8254 )
8255 }
8256
8257 pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
8258 match self.state.storage_ui_mode.clone() {
8259 StorageUiMode::Menu => {
8260 let index = self.state.storage_menu_index;
8261 match index {
8262 0 => {
8263 let opts = self.state.storage_store_options();
8264 if opts.is_empty() {
8265 self.state.push_log("Nothing loose to store.");
8266 return Ok(());
8267 }
8268 self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
8269 }
8270 1 => {
8271 let opts = self.state.storage_vault_options();
8272 if opts.is_empty() {
8273 self.state.push_log("Vault is empty.");
8274 return Ok(());
8275 }
8276 self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
8277 }
8278 n => {
8279 let dest = self
8280 .state
8281 .storage_panel
8282 .as_ref()
8283 .and_then(|p| p.ship_destinations.get(n - 2))
8284 .cloned();
8285 let Some(dest) = dest else {
8286 return Ok(());
8287 };
8288 let opts = self.state.storage_vault_options();
8289 if opts.is_empty() {
8290 self.state
8291 .push_log("Vault is empty — nothing to ship.");
8292 return Ok(());
8293 }
8294 self.state.storage_ui_mode = StorageUiMode::ShipPick {
8295 dest_building_id: dest.building_id,
8296 dest_label: dest.label,
8297 index: 0,
8298 };
8299 }
8300 }
8301 }
8302 StorageUiMode::StorePick { index } => {
8303 let opts = self.state.storage_store_options();
8304 let Some(opt) = opts.get(index) else {
8305 self.state.push_log("Nothing loose to store.");
8306 self.state.storage_ui_mode = StorageUiMode::Menu;
8307 return Ok(());
8308 };
8309 self.state.storage_ui_mode = StorageUiMode::StoreAmount {
8310 pick_index: index,
8311 item_instance_id: opt.item_instance_id,
8312 label: opt.label.clone(),
8313 max_qty: opt.quantity.max(1),
8314 input: String::new(),
8315 };
8316 }
8317 StorageUiMode::TakePick { index } => {
8318 let opts = self.state.storage_vault_options();
8319 let Some(opt) = opts.get(index) else {
8320 self.state.push_log("Vault is empty.");
8321 self.state.storage_ui_mode = StorageUiMode::Menu;
8322 return Ok(());
8323 };
8324 self.state.storage_ui_mode = StorageUiMode::TakeAmount {
8325 pick_index: index,
8326 item_instance_id: opt.item_instance_id,
8327 label: opt.label.clone(),
8328 max_qty: opt.quantity.max(1),
8329 input: String::new(),
8330 };
8331 }
8332 StorageUiMode::ShipPick {
8333 dest_building_id,
8334 dest_label,
8335 index,
8336 } => {
8337 let opts = self.state.storage_vault_options();
8338 let Some(opt) = opts.get(index) else {
8339 self.state
8340 .push_log("Vault is empty — nothing to ship.");
8341 self.state.storage_ui_mode = StorageUiMode::Menu;
8342 return Ok(());
8343 };
8344 self.state.storage_ui_mode = StorageUiMode::ShipAmount {
8345 dest_building_id,
8346 dest_label,
8347 pick_index: index,
8348 item_instance_id: opt.item_instance_id,
8349 label: opt.label.clone(),
8350 max_qty: opt.quantity.max(1),
8351 input: String::new(),
8352 };
8353 }
8354 StorageUiMode::StoreAmount {
8355 item_instance_id,
8356 max_qty,
8357 input,
8358 ..
8359 } => {
8360 let Some(qty) = parse_storage_quantity(&input) else {
8361 self.state
8362 .push_log("Enter a quantity (blank or 0 = all).");
8363 return Ok(());
8364 };
8365 let qty = qty.map(|n| n.min(max_qty).max(1));
8366 self.storage_store(item_instance_id, qty).await?;
8367 self.state.storage_ui_mode = StorageUiMode::Menu;
8368 }
8369 StorageUiMode::TakeAmount {
8370 item_instance_id,
8371 max_qty,
8372 input,
8373 ..
8374 } => {
8375 let Some(qty) = parse_storage_quantity(&input) else {
8376 self.state
8377 .push_log("Enter a quantity (blank or 0 = all).");
8378 return Ok(());
8379 };
8380 let qty = qty.map(|n| n.min(max_qty).max(1));
8381 self.storage_take(item_instance_id, qty).await?;
8382 self.state.storage_ui_mode = StorageUiMode::Menu;
8383 }
8384 StorageUiMode::ShipAmount {
8385 dest_building_id,
8386 item_instance_id,
8387 max_qty,
8388 input,
8389 ..
8390 } => {
8391 let Some(qty) = parse_storage_quantity(&input) else {
8392 self.state
8393 .push_log("Enter a quantity (blank or 0 = all).");
8394 return Ok(());
8395 };
8396 let qty = qty.map(|n| n.min(max_qty).max(1));
8397 self.storage_ship(dest_building_id, item_instance_id, qty)
8398 .await?;
8399 self.state.storage_ui_mode = StorageUiMode::Menu;
8400 }
8401 }
8402 Ok(())
8403 }
8404
8405 pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
8406 match self.state.bank_ui_mode.clone() {
8407 BankUiMode::Menu => {
8408 let choice = self
8409 .state
8410 .bank_menu_options()
8411 .get(self.state.bank_menu_index)
8412 .copied()
8413 .unwrap_or("Deposit…");
8414 match choice {
8415 "Withdraw…" => {
8416 self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
8417 input: String::new(),
8418 };
8419 }
8420 "Deposit all" => self.bank_deposit(0).await?,
8421 "Withdraw all" => self.bank_withdraw(0).await?,
8422 "Transfer…" => {
8423 self.state.bank_ui_mode = BankUiMode::TransferName {
8424 input: String::new(),
8425 };
8426 }
8427 _ => {
8428 self.state.bank_ui_mode = BankUiMode::DepositAmount {
8429 input: String::new(),
8430 };
8431 }
8432 }
8433 }
8434 BankUiMode::DepositAmount { input } => {
8435 let Some(amount) = parse_bank_copper_amount(&input) else {
8436 self.state
8437 .push_log("Enter a copper amount (blank or 0 = everything on person).");
8438 return Ok(());
8439 };
8440 self.bank_deposit(amount).await?;
8441 self.state.bank_ui_mode = BankUiMode::Menu;
8442 }
8443 BankUiMode::WithdrawAmount { input } => {
8444 let Some(amount) = parse_bank_copper_amount(&input) else {
8445 self.state
8446 .push_log("Enter a copper amount (blank or 0 = full ledger).");
8447 return Ok(());
8448 };
8449 self.bank_withdraw(amount).await?;
8450 self.state.bank_ui_mode = BankUiMode::Menu;
8451 }
8452 BankUiMode::TransferName { input } => {
8453 let name = input.trim().to_string();
8454 if name.is_empty() {
8455 self.state.push_log("Enter the recipient character name.");
8456 return Ok(());
8457 }
8458 self.state.bank_ui_mode = BankUiMode::TransferAmount {
8459 to_name: name,
8460 input: String::new(),
8461 };
8462 }
8463 BankUiMode::TransferAmount { to_name, input } => {
8464 let amount: u64 = match input.trim().parse() {
8465 Ok(v) if v > 0 => v,
8466 _ => {
8467 self.state
8468 .push_log("Enter a positive copper amount to transfer.");
8469 return Ok(());
8470 }
8471 };
8472 self.bank_transfer(None, to_name, amount).await?;
8473 self.state.bank_ui_mode = BankUiMode::Menu;
8474 }
8475 }
8476 Ok(())
8477 }
8478
8479 pub fn bank_transfer_back(&mut self) {
8480 match &self.state.bank_ui_mode {
8481 BankUiMode::TransferAmount { to_name, .. } => {
8482 self.state.bank_ui_mode = BankUiMode::TransferName {
8483 input: to_name.clone(),
8484 };
8485 }
8486 BankUiMode::TransferName { .. }
8487 | BankUiMode::DepositAmount { .. }
8488 | BankUiMode::WithdrawAmount { .. } => {
8489 self.state.bank_ui_mode = BankUiMode::Menu;
8490 }
8491 BankUiMode::Menu => {}
8492 }
8493 }
8494
8495 pub fn bank_transfer_append_char(&mut self, c: char) {
8496 match &mut self.state.bank_ui_mode {
8497 BankUiMode::TransferName { input } => {
8498 if input.len() < 32 && !c.is_control() {
8499 input.push(c);
8500 }
8501 }
8502 BankUiMode::DepositAmount { input }
8503 | BankUiMode::WithdrawAmount { input }
8504 | BankUiMode::TransferAmount { input, .. } => {
8505 if c.is_ascii_digit() && input.len() < 12 {
8506 input.push(c);
8507 }
8508 }
8509 BankUiMode::Menu => {}
8510 }
8511 }
8512
8513 pub fn bank_transfer_backspace(&mut self) {
8514 match &mut self.state.bank_ui_mode {
8515 BankUiMode::TransferName { input }
8516 | BankUiMode::DepositAmount { input }
8517 | BankUiMode::WithdrawAmount { input }
8518 | BankUiMode::TransferAmount { input, .. } => {
8519 input.pop();
8520 }
8521 BankUiMode::Menu => {}
8522 }
8523 }
8524
8525 pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
8526 let npc_id = self
8527 .state
8528 .bank_panel
8529 .as_ref()
8530 .map(|p| p.npc_id.clone());
8531 self.state.clear_bank_panel();
8532 if let Some(npc_id) = npc_id {
8533 self.seq += 1;
8534 self.session
8535 .submit_intent(Intent::BankClose {
8536 entity_id: self.state.entity_id,
8537 npc_id,
8538 seq: self.seq,
8539 })
8540 .await?;
8541 self.state.intents_sent += 1;
8542 }
8543 Ok(())
8544 }
8545
8546 pub async fn storage_store(
8547 &mut self,
8548 item_instance_id: uuid::Uuid,
8549 quantity: Option<u32>,
8550 ) -> anyhow::Result<()> {
8551 let Some(panel) = self.state.storage_panel.clone() else {
8552 return Ok(());
8553 };
8554 self.seq += 1;
8555 self.session
8556 .submit_intent(Intent::StorageStore {
8557 entity_id: self.state.entity_id,
8558 npc_id: panel.npc_id,
8559 item_instance_id,
8560 quantity,
8561 seq: self.seq,
8562 })
8563 .await?;
8564 self.state.intents_sent += 1;
8565 Ok(())
8566 }
8567
8568 pub async fn storage_take(
8569 &mut self,
8570 item_instance_id: uuid::Uuid,
8571 quantity: Option<u32>,
8572 ) -> anyhow::Result<()> {
8573 let Some(panel) = self.state.storage_panel.clone() else {
8574 return Ok(());
8575 };
8576 self.seq += 1;
8577 self.session
8578 .submit_intent(Intent::StorageTake {
8579 entity_id: self.state.entity_id,
8580 npc_id: panel.npc_id,
8581 item_instance_id,
8582 quantity,
8583 seq: self.seq,
8584 })
8585 .await?;
8586 self.state.intents_sent += 1;
8587 Ok(())
8588 }
8589
8590 pub async fn storage_ship(
8591 &mut self,
8592 dest_building_id: String,
8593 item_instance_id: uuid::Uuid,
8594 quantity: Option<u32>,
8595 ) -> anyhow::Result<()> {
8596 let Some(panel) = self.state.storage_panel.clone() else {
8597 return Ok(());
8598 };
8599 self.seq += 1;
8600 self.session
8601 .submit_intent(Intent::StorageShip {
8602 entity_id: self.state.entity_id,
8603 npc_id: panel.npc_id,
8604 dest_building_id,
8605 item_instance_id,
8606 quantity,
8607 seq: self.seq,
8608 })
8609 .await?;
8610 self.state.intents_sent += 1;
8611 Ok(())
8612 }
8613
8614 pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
8615 let npc_id = self
8616 .state
8617 .storage_panel
8618 .as_ref()
8619 .map(|p| p.npc_id.clone());
8620 self.state.clear_storage_panel();
8621 if let Some(npc_id) = npc_id {
8622 self.seq += 1;
8623 self.session
8624 .submit_intent(Intent::StorageClose {
8625 entity_id: self.state.entity_id,
8626 npc_id,
8627 seq: self.seq,
8628 })
8629 .await?;
8630 self.state.intents_sent += 1;
8631 }
8632 Ok(())
8633 }
8634
8635 pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
8636 let npc_id = self
8637 .state
8638 .market_panel
8639 .as_ref()
8640 .map(|p| p.npc_id.clone());
8641 self.state.clear_market_panel();
8642 if let Some(npc_id) = npc_id {
8643 self.seq += 1;
8644 self.session
8645 .submit_intent(Intent::MarketClose {
8646 entity_id: self.state.entity_id,
8647 npc_id,
8648 seq: self.seq,
8649 })
8650 .await?;
8651 self.state.intents_sent += 1;
8652 }
8653 Ok(())
8654 }
8655
8656 pub fn market_move_selection(&mut self, delta: i32) {
8657 let indices = self.state.market_filtered_listing_indices();
8658 let n = indices.len();
8659 if n == 0 {
8660 self.state.market_menu_index = 0;
8661 return;
8662 }
8663 let cur = self.state.market_menu_index as i32;
8664 self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
8665 }
8666
8667 pub fn market_page_selection(&mut self, pages: i32) {
8668 let indices = self.state.market_filtered_listing_indices();
8669 let n = indices.len();
8670 if n == 0 {
8671 self.state.market_menu_index = 0;
8672 return;
8673 }
8674 self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
8675 }
8676
8677 pub fn market_list_page(&mut self, pages: i32) {
8678 match &self.state.market_ui_mode {
8679 MarketUiMode::ListSource { index } => {
8680 let n = self.state.market_list_source_options().len();
8681 if n == 0 {
8682 return;
8683 }
8684 let next = page_list_index(*index, pages, n);
8685 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
8686 }
8687 MarketUiMode::ListPick { source, index } => {
8688 let opts = self.state.market_list_item_options(source);
8689 let n = opts.len();
8690 if n == 0 {
8691 return;
8692 }
8693 let next = page_list_index(*index, pages, n);
8694 self.state.market_ui_mode = MarketUiMode::ListPick {
8695 source: source.clone(),
8696 index: next,
8697 };
8698 }
8699 _ => {}
8700 }
8701 }
8702
8703 pub fn market_cycle_category(&mut self, delta: i32) {
8704 let groups = self.state.market_available_category_groups();
8705 let mut labels: Vec<Option<&'static str>> = vec![None];
8707 labels.extend(groups.into_iter().map(Some));
8708 let n = labels.len() as i32;
8709 let cur = labels
8710 .iter()
8711 .position(|g| *g == self.state.market_category_filter)
8712 .unwrap_or(0) as i32;
8713 let next = (cur + delta).rem_euclid(n) as usize;
8714 self.state.market_category_filter = labels[next];
8715 self.state.market_menu_index = 0;
8716 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8717 let source = source.clone();
8718 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8719 }
8720 }
8721
8722 pub fn focus_market_filter(&mut self) {
8723 self.state.market_filter_focused = true;
8724 }
8725
8726 pub fn append_market_filter_char(&mut self, ch: char) {
8727 if !self.state.market_filter_focused {
8728 return;
8729 }
8730 if ch.is_control() {
8731 return;
8732 }
8733 if self.state.market_filter.len() < 48 {
8734 self.state.market_filter.push(ch);
8735 self.state.market_menu_index = 0;
8736 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8737 let source = source.clone();
8738 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8739 }
8740 }
8741 }
8742
8743 pub fn market_filter_backspace(&mut self) {
8744 if !self.state.market_filter_focused {
8745 return;
8746 }
8747 self.state.market_filter.pop();
8748 self.state.market_menu_index = 0;
8749 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8750 let source = source.clone();
8751 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8752 }
8753 }
8754
8755 pub fn clear_or_blur_market_filter(&mut self) -> bool {
8757 if self.state.market_filter_focused {
8758 if !self.state.market_filter.is_empty() {
8759 self.state.market_filter.clear();
8760 self.state.market_menu_index = 0;
8761 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8762 let source = source.clone();
8763 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8764 }
8765 return true;
8766 }
8767 self.state.market_filter_focused = false;
8768 return true;
8769 }
8770 if !self.state.market_filter.is_empty() {
8771 self.state.market_filter.clear();
8772 self.state.market_menu_index = 0;
8773 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8774 let source = source.clone();
8775 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8776 }
8777 return true;
8778 }
8779 false
8780 }
8781
8782 pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
8783 if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
8784 return self.market_confirm_buy(listing_id, qty).await;
8785 }
8786 let Some(panel) = self.state.market_panel.clone() else {
8787 return Ok(());
8788 };
8789 let indices = self.state.market_filtered_listing_indices();
8790 let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
8791 return Ok(());
8792 };
8793 let Some(listing) = panel.listings.get(raw_idx) else {
8794 return Ok(());
8795 };
8796 if listing.mine {
8797 self.seq += 1;
8798 self.session
8799 .submit_intent(Intent::MarketDelist {
8800 entity_id: self.state.entity_id,
8801 npc_id: panel.npc_id.clone(),
8802 listing_id: listing.listing_id,
8803 dest: flatland_protocol::GoodsLocation::Person,
8804 seq: self.seq,
8805 })
8806 .await?;
8807 self.state.intents_sent += 1;
8808 return Ok(());
8809 }
8810 let qty = 1u32.min(listing.quantity).max(1);
8811 let line = listing.unit_price_copper.saturating_mul(qty as u64);
8812 self.state.market_buy_confirm = Some((
8813 listing.listing_id,
8814 qty,
8815 listing.unit_price_copper,
8816 line,
8817 listing.display_name.clone(),
8818 ));
8819 Ok(())
8820 }
8821
8822 pub async fn market_confirm_buy(
8823 &mut self,
8824 listing_id: uuid::Uuid,
8825 quantity: u32,
8826 ) -> anyhow::Result<()> {
8827 let Some(panel) = self.state.market_panel.clone() else {
8828 self.state.market_buy_confirm = None;
8829 return Ok(());
8830 };
8831 self.state.market_buy_confirm = None;
8832 self.seq += 1;
8833 self.session
8834 .submit_intent(Intent::MarketBuy {
8835 entity_id: self.state.entity_id,
8836 npc_id: panel.npc_id,
8837 listing_id,
8838 quantity,
8839 dest: flatland_protocol::GoodsLocation::Person,
8840 seq: self.seq,
8841 })
8842 .await?;
8843 self.state.intents_sent += 1;
8844 Ok(())
8845 }
8846
8847 pub fn market_begin_list(&mut self) {
8849 if self.state.market_panel.is_none() {
8850 return;
8851 }
8852 let sources = self.state.market_list_source_options();
8853 if sources.is_empty() {
8854 self.state.push_log("Nothing to list from.");
8855 return;
8856 }
8857 if sources.len() == 1 {
8859 let (source, _) = sources[0].clone();
8860 let opts = self.state.market_list_item_options(&source);
8861 if opts.is_empty() {
8862 self.state.push_log("Nothing loose to list.");
8863 return;
8864 }
8865 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8866 self.state.market_buy_confirm = None;
8867 return;
8868 }
8869 self.state.market_buy_confirm = None;
8870 self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
8871 }
8872
8873 pub fn market_ui_back(&mut self) {
8874 self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
8875 MarketUiMode::Browse => MarketUiMode::Browse,
8876 MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
8877 MarketUiMode::ListPick { .. } => {
8878 if self.state.market_list_source_options().len() <= 1 {
8879 MarketUiMode::Browse
8880 } else {
8881 MarketUiMode::ListSource { index: 0 }
8882 }
8883 }
8884 MarketUiMode::ListAmount {
8885 source,
8886 pick_index,
8887 ..
8888 } => MarketUiMode::ListPick {
8889 source,
8890 index: pick_index,
8891 },
8892 MarketUiMode::ListPrice {
8893 source,
8894 item_instance_id,
8895 label,
8896 max_qty,
8897 quantity,
8898 ..
8899 } => {
8900 let input = quantity
8901 .map(|q| q.to_string())
8902 .unwrap_or_default();
8903 MarketUiMode::ListAmount {
8904 source,
8905 pick_index: 0,
8906 item_instance_id,
8907 label,
8908 max_qty,
8909 input,
8910 }
8911 }
8912 };
8913 }
8914
8915 pub fn market_list_move(&mut self, delta: i32) {
8916 match &self.state.market_ui_mode {
8917 MarketUiMode::ListSource { index } => {
8918 let n = self.state.market_list_source_options().len();
8919 if n == 0 {
8920 return;
8921 }
8922 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8923 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
8924 }
8925 MarketUiMode::ListPick { source, index } => {
8926 let opts = self.state.market_list_item_options(source);
8927 let n = opts.len();
8928 if n == 0 {
8929 return;
8930 }
8931 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8932 self.state.market_ui_mode = MarketUiMode::ListPick {
8933 source: source.clone(),
8934 index: next,
8935 };
8936 }
8937 _ => {}
8938 }
8939 }
8940
8941 pub fn market_list_amount_append_char(&mut self, c: char) {
8942 if !c.is_ascii_digit() {
8943 return;
8944 }
8945 match &mut self.state.market_ui_mode {
8946 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
8947 if input.len() < 12 {
8948 input.push(c);
8949 }
8950 }
8951 _ => {}
8952 }
8953 }
8954
8955 pub fn market_list_amount_backspace(&mut self) {
8956 match &mut self.state.market_ui_mode {
8957 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
8958 input.pop();
8959 }
8960 _ => {}
8961 }
8962 }
8963
8964 pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
8965 match self.state.market_ui_mode.clone() {
8966 MarketUiMode::Browse => Ok(()),
8967 MarketUiMode::ListSource { index } => {
8968 let sources = self.state.market_list_source_options();
8969 let Some((source, _)) = sources.get(index).cloned() else {
8970 return Ok(());
8971 };
8972 let opts = self.state.market_list_item_options(&source);
8973 if opts.is_empty() {
8974 self.state.push_log("Nothing to list from that source.");
8975 return Ok(());
8976 }
8977 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8978 Ok(())
8979 }
8980 MarketUiMode::ListPick { source, index } => {
8981 let opts = self.state.market_list_item_options(&source);
8982 let Some(opt) = opts.get(index) else {
8983 self.state.push_log("Nothing to list.");
8984 self.state.market_ui_mode = MarketUiMode::Browse;
8985 return Ok(());
8986 };
8987 self.state.market_ui_mode = MarketUiMode::ListAmount {
8988 source,
8989 pick_index: index,
8990 item_instance_id: opt.item_instance_id,
8991 label: opt.label.clone(),
8992 max_qty: opt.quantity.max(1),
8993 input: String::new(),
8994 };
8995 Ok(())
8996 }
8997 MarketUiMode::ListAmount {
8998 source,
8999 item_instance_id,
9000 label,
9001 max_qty,
9002 input,
9003 ..
9004 } => {
9005 let Some(qty_opt) = parse_storage_quantity(&input) else {
9006 self.state.push_log("Enter a quantity (blank = all).");
9007 return Ok(());
9008 };
9009 if let Some(q) = qty_opt {
9010 if q > max_qty {
9011 self.state
9012 .push_log(format!("Only {max_qty} available."));
9013 return Ok(());
9014 }
9015 }
9016 self.state.market_ui_mode = MarketUiMode::ListPrice {
9017 source,
9018 item_instance_id,
9019 label,
9020 quantity: qty_opt,
9021 max_qty,
9022 input: String::new(),
9023 };
9024 Ok(())
9025 }
9026 MarketUiMode::ListPrice {
9027 source,
9028 item_instance_id,
9029 label,
9030 quantity,
9031 input,
9032 ..
9033 } => {
9034 let price = input.trim().parse::<u64>().unwrap_or(0);
9035 if price == 0 {
9036 self.state.push_log("Enter a unit price of at least 1 copper.");
9037 return Ok(());
9038 }
9039 let Some(panel) = self.state.market_panel.clone() else {
9040 self.state.market_ui_mode = MarketUiMode::Browse;
9041 return Ok(());
9042 };
9043 let goods = match source {
9044 MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
9045 MarketListSourceKind::TownStorage { building_id } => {
9046 flatland_protocol::GoodsLocation::TownStorage { building_id }
9047 }
9048 };
9049 self.seq += 1;
9050 self.session
9051 .submit_intent(Intent::MarketList {
9052 entity_id: self.state.entity_id,
9053 npc_id: panel.npc_id,
9054 source: goods,
9055 item_instance_id,
9056 quantity,
9057 unit_price_copper: price,
9058 seq: self.seq,
9059 })
9060 .await?;
9061 self.state.intents_sent += 1;
9062 self.state
9063 .push_log(format!("Listing {label} @ {price} cp…"));
9064 self.state.market_ui_mode = MarketUiMode::Browse;
9065 Ok(())
9066 }
9067 }
9068 }
9069
9070 pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
9072 let return_to_verbs = self.state.npc_verb_target.is_some();
9073 self.close_shop_menu().await?;
9074 if return_to_verbs {
9075 self.state.show_npc_verb_menu = true;
9076 }
9077 Ok(())
9078 }
9079
9080 pub fn shop_tab_toggle(&mut self) {
9081 self.state.shop_tab = match self.state.shop_tab {
9082 ShopTab::Buy => ShopTab::Sell,
9083 ShopTab::Sell => ShopTab::Buy,
9084 };
9085 self.state.shop_menu_index = 0;
9086 if self.state.shop_tab == ShopTab::Sell {
9087 self.state.shop_quantity_set_max();
9088 }
9089 self.state.clamp_shop_selection();
9090 }
9091
9092 pub fn shop_menu_move(&mut self, delta: i32) {
9093 self.state.shop_menu_move(delta);
9094 }
9095
9096 pub fn shop_quantity_adjust(&mut self, delta: i32) {
9097 self.state.shop_quantity_adjust(delta);
9098 }
9099
9100 pub fn shop_quantity_set_max(&mut self) {
9101 self.state.shop_quantity_set_max();
9102 }
9103
9104 pub fn toggle_quest_menu(&mut self) {
9105 self.state.show_quest_menu = !self.state.show_quest_menu;
9106 if self.state.show_quest_menu {
9107 self.state.quest_menu_index = 0;
9108 self.state.quest_withdraw_confirm = false;
9109 self.state.show_workers_menu = false;
9110 }
9111 }
9112
9113 pub fn toggle_workers_menu(&mut self) {
9114 if self.state.show_workers_menu {
9115 self.close_workers_menu_ui();
9116 } else {
9117 self.state.show_workers_menu = true;
9118 self.state.workers_menu_index = 0;
9119 self.state.show_quest_menu = false;
9120 self.close_worker_give_picker();
9121 self.close_worker_give_target_picker();
9122 self.close_worker_take_picker();
9123 self.close_worker_teach_picker();
9124 self.cancel_worker_rename();
9125 }
9126 }
9127
9128 pub fn close_workers_menu_ui(&mut self) {
9130 self.state.show_workers_menu = false;
9131 self.close_worker_give_picker();
9132 self.close_worker_give_target_picker();
9133 self.close_worker_take_picker();
9134 self.close_worker_teach_picker();
9135 self.cancel_worker_rename();
9136 }
9137
9138 pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
9140 let Some(idx) = self
9141 .state
9142 .hired_workers
9143 .iter()
9144 .position(|w| w.instance_id == instance_id)
9145 else {
9146 anyhow::bail!("worker not found");
9147 };
9148 let label = self.state.hired_workers[idx].label.clone();
9149 self.state.show_workers_menu = true;
9150 self.state.workers_menu_index = idx;
9151 self.state.show_quest_menu = false;
9152 self.close_worker_give_picker();
9153 self.close_worker_give_target_picker();
9154 self.close_worker_take_picker();
9155 self.close_worker_teach_picker();
9156 self.cancel_worker_rename();
9157 self.set_worker_attending(instance_id, true).await?;
9158 self.state
9159 .push_log(format!("Managing {label} — job paused while menu is open"));
9160 Ok(())
9161 }
9162
9163 pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
9165 self.close_workers_menu_ui();
9166 self.release_worker_attend().await
9167 }
9168
9169 async fn set_worker_attending(
9170 &mut self,
9171 instance_id: &str,
9172 attending: bool,
9173 ) -> anyhow::Result<()> {
9174 if attending {
9175 if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9176 return Ok(());
9177 }
9178 if let Some(prev) = self.state.attending_worker_instance_id.clone() {
9180 if prev != instance_id {
9181 self.send_attend_hired_worker(&prev, false).await?;
9182 }
9183 }
9184 self.send_attend_hired_worker(instance_id, true).await?;
9185 self.state.attending_worker_instance_id = Some(instance_id.to_string());
9186 } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9187 self.send_attend_hired_worker(instance_id, false).await?;
9188 self.state.attending_worker_instance_id = None;
9189 }
9190 Ok(())
9191 }
9192
9193 pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
9194 let Some(id) = self.state.attending_worker_instance_id.take() else {
9195 return Ok(());
9196 };
9197 self.send_attend_hired_worker(&id, false).await
9198 }
9199
9200 async fn send_attend_hired_worker(
9201 &mut self,
9202 worker_instance_id: &str,
9203 attending: bool,
9204 ) -> anyhow::Result<()> {
9205 self.seq += 1;
9206 self.session
9207 .submit_intent(Intent::AttendHiredWorker {
9208 entity_id: self.state.entity_id,
9209 worker_instance_id: worker_instance_id.to_string(),
9210 attending,
9211 seq: self.seq,
9212 })
9213 .await?;
9214 self.state.intents_sent += 1;
9215 Ok(())
9216 }
9217
9218 pub fn workers_menu_move(&mut self, delta: i32) {
9219 let n = self.state.hired_workers.len();
9220 if n == 0 {
9221 return;
9222 }
9223 let idx = self.state.workers_menu_index as i32;
9224 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9225 }
9226
9227 pub fn toggle_workers_menu_compact(&mut self) {
9228 self.state.workers_menu_compact = !self.state.workers_menu_compact;
9229 let mut cfg = crate::client_config::ClientConfig::load();
9230 let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
9231 }
9232
9233 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
9234 let Some(worker) = self
9235 .state
9236 .hired_workers
9237 .get(self.state.workers_menu_index)
9238 .cloned()
9239 else {
9240 anyhow::bail!("no worker selected");
9241 };
9242 self.seq += 1;
9243 self.session
9244 .submit_intent(Intent::DismissWorker {
9245 entity_id: self.state.entity_id,
9246 worker_instance_id: worker.instance_id.clone(),
9247 seq: self.seq,
9248 })
9249 .await?;
9250 self.state.intents_sent += 1;
9251 self.state
9252 .hired_workers
9253 .retain(|w| w.instance_id != worker.instance_id);
9254 if self.state.workers_menu_index >= self.state.hired_workers.len() {
9255 self.state.workers_menu_index = self
9256 .state
9257 .hired_workers
9258 .len()
9259 .saturating_sub(1);
9260 }
9261 self.state.push_log(format!("Dismissed {}", worker.label));
9262 Ok(())
9263 }
9264
9265 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
9266 let Some(worker) = self
9267 .state
9268 .hired_workers
9269 .get(self.state.workers_menu_index)
9270 .cloned()
9271 else {
9272 anyhow::bail!("no worker selected");
9273 };
9274 let mode = match worker.mode {
9275 flatland_protocol::WorkerModeView::Companion => "job_loop",
9276 flatland_protocol::WorkerModeView::JobLoop => "idle",
9277 flatland_protocol::WorkerModeView::Idle => "companion",
9278 };
9279 self.seq += 1;
9280 self.session
9281 .submit_intent(Intent::SetWorkerMode {
9282 entity_id: self.state.entity_id,
9283 worker_instance_id: worker.instance_id,
9284 mode: mode.into(),
9285 seq: self.seq,
9286 })
9287 .await?;
9288 self.state.intents_sent += 1;
9289 Ok(())
9290 }
9291
9292 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
9293 if self.state.hired_workers.is_empty() {
9294 return self.hire_worker_laborer().await;
9295 }
9296 self.workers_toggle_mode_selected().await
9297 }
9298
9299 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
9302 let row = self
9303 .state
9304 .inventory_selected_row()
9305 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
9306 .clone();
9307 if row.from != flatland_protocol::InventoryLocation::Root {
9308 anyhow::bail!("select a carried item to give");
9309 }
9310 let Some(instance_id) = row.stack.item_instance_id else {
9311 anyhow::bail!("that stack can't be given");
9312 };
9313 let options = self.nearby_worker_give_targets();
9314 if options.is_empty() {
9315 anyhow::bail!(
9316 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
9317 );
9318 }
9319 let item_label = row
9320 .stack
9321 .display_name
9322 .as_deref()
9323 .unwrap_or(&row.stack.template_id)
9324 .to_string();
9325 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
9326 item_instance_id: instance_id,
9327 item_label,
9328 quantity: None,
9329 options,
9330 });
9331 self.state.worker_give_target_picker_index = 0;
9332 self.state.show_worker_give_target_picker = true;
9333 self.state.show_inventory_menu = false;
9335 Ok(())
9336 }
9337
9338 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
9340 let (px, py, _) = self.state.player_position_with_z();
9341 let mut options: Vec<WorkerGiveTargetOption> = self
9342 .state
9343 .hired_workers
9344 .iter()
9345 .filter_map(|w| {
9346 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
9347 if dist > WORKER_GIVE_RANGE_M {
9348 return None;
9349 }
9350 Some(WorkerGiveTargetOption {
9351 instance_id: w.instance_id.clone(),
9352 label: w.label.clone(),
9353 distance_m: dist,
9354 })
9355 })
9356 .collect();
9357 options.sort_by(|a, b| {
9358 a.distance_m
9359 .partial_cmp(&b.distance_m)
9360 .unwrap_or(std::cmp::Ordering::Equal)
9361 });
9362 options
9363 }
9364
9365 pub fn close_worker_give_target_picker(&mut self) {
9366 self.state.show_worker_give_target_picker = false;
9367 self.state.worker_give_target_picker = None;
9368 self.state.worker_give_target_picker_index = 0;
9369 }
9370
9371 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
9372 let Some(picker) = &self.state.worker_give_target_picker else {
9373 return;
9374 };
9375 let n = picker.options.len();
9376 if n == 0 {
9377 return;
9378 }
9379 let idx = self.state.worker_give_target_picker_index as i32;
9380 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9381 }
9382
9383 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
9384 let Some(picker) = self.state.worker_give_target_picker.clone() else {
9385 anyhow::bail!("give target picker not open");
9386 };
9387 let Some(opt) = picker
9388 .options
9389 .get(self.state.worker_give_target_picker_index)
9390 .cloned()
9391 else {
9392 anyhow::bail!("no worker selected");
9393 };
9394 let Some(worker) = self
9395 .state
9396 .hired_workers
9397 .iter()
9398 .find(|w| w.instance_id == opt.instance_id)
9399 .cloned()
9400 else {
9401 self.close_worker_give_target_picker();
9402 anyhow::bail!("worker no longer hired");
9403 };
9404 self.give_item_to_worker(
9405 &worker.instance_id,
9406 &worker.label,
9407 worker.x,
9408 worker.y,
9409 picker.item_instance_id,
9410 &picker.item_label,
9411 picker.quantity,
9412 )
9413 .await?;
9414 self.close_worker_give_target_picker();
9415 Ok(())
9416 }
9417
9418 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
9420 self.open_worker_give_target_picker()
9421 }
9422
9423 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
9425 let Some(worker) = self
9426 .state
9427 .hired_workers
9428 .get(self.state.workers_menu_index)
9429 .cloned()
9430 else {
9431 anyhow::bail!("select a hired worker first");
9432 };
9433 let (px, py, _) = self.state.player_position_with_z();
9434 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9435 if dist > WORKER_GIVE_RANGE_M {
9436 anyhow::bail!(
9437 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
9438 worker.label
9439 );
9440 }
9441 let options = self.state.giveable_inventory_options();
9442 if options.is_empty() {
9443 anyhow::bail!("nothing in inventory to give");
9444 }
9445 self.state.worker_give_picker = Some(WorkerGivePicker {
9446 worker_instance_id: worker.instance_id,
9447 worker_label: worker.label,
9448 options,
9449 });
9450 self.state.worker_give_picker_index = 0;
9451 self.state.show_worker_give_picker = true;
9452 Ok(())
9453 }
9454
9455 pub fn close_worker_give_picker(&mut self) {
9456 self.state.show_worker_give_picker = false;
9457 self.state.worker_give_picker = None;
9458 self.state.worker_give_picker_index = 0;
9459 }
9460
9461 pub fn worker_give_picker_move(&mut self, delta: i32) {
9462 let Some(picker) = &self.state.worker_give_picker else {
9463 return;
9464 };
9465 let n = picker.options.len();
9466 if n == 0 {
9467 return;
9468 }
9469 let idx = self.state.worker_give_picker_index as i32;
9470 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9471 }
9472
9473 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
9475 let Some(picker) = self.state.worker_give_picker.clone() else {
9476 anyhow::bail!("give picker not open");
9477 };
9478 let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
9479 anyhow::bail!("no item selected");
9480 };
9481 let Some(worker) = self
9482 .state
9483 .hired_workers
9484 .iter()
9485 .find(|w| w.instance_id == picker.worker_instance_id)
9486 .cloned()
9487 else {
9488 self.close_worker_give_picker();
9489 anyhow::bail!("worker no longer hired");
9490 };
9491 self.give_item_to_worker(
9492 &worker.instance_id,
9493 &worker.label,
9494 worker.x,
9495 worker.y,
9496 opt.item_instance_id,
9497 &opt.label,
9498 None,
9499 )
9500 .await?;
9501 let options = self.state.giveable_inventory_options();
9503 if options.is_empty() {
9504 self.close_worker_give_picker();
9505 } else {
9506 self.state.worker_give_picker = Some(WorkerGivePicker {
9507 worker_instance_id: picker.worker_instance_id,
9508 worker_label: picker.worker_label,
9509 options,
9510 });
9511 if self.state.worker_give_picker_index
9512 >= self
9513 .state
9514 .worker_give_picker
9515 .as_ref()
9516 .map(|p| p.options.len())
9517 .unwrap_or(0)
9518 {
9519 self.state.worker_give_picker_index = self
9520 .state
9521 .worker_give_picker
9522 .as_ref()
9523 .map(|p| p.options.len().saturating_sub(1))
9524 .unwrap_or(0);
9525 }
9526 }
9527 Ok(())
9528 }
9529
9530 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
9532 let Some(worker) = self
9533 .state
9534 .hired_workers
9535 .get(self.state.workers_menu_index)
9536 .cloned()
9537 else {
9538 anyhow::bail!("select a hired worker first");
9539 };
9540 let (px, py, _) = self.state.player_position_with_z();
9541 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9542 if dist > WORKER_GIVE_RANGE_M {
9543 anyhow::bail!(
9544 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
9545 worker.label
9546 );
9547 }
9548 let options = self.state.teachable_blueprint_options(&worker);
9549 if options.is_empty() {
9550 anyhow::bail!("no recipes you know that {} still needs", worker.label);
9551 }
9552 self.state.worker_teach_picker = Some(WorkerTeachPicker {
9553 worker_instance_id: worker.instance_id,
9554 worker_label: worker.label,
9555 worker_level: worker.level,
9556 options,
9557 });
9558 self.state.worker_teach_picker_index = 0;
9559 self.state.show_worker_teach_picker = true;
9560 Ok(())
9561 }
9562
9563 pub fn close_worker_teach_picker(&mut self) {
9564 self.state.show_worker_teach_picker = false;
9565 self.state.worker_teach_picker = None;
9566 self.state.worker_teach_picker_index = 0;
9567 }
9568
9569 pub fn worker_teach_picker_move(&mut self, delta: i32) {
9570 let Some(picker) = &self.state.worker_teach_picker else {
9571 return;
9572 };
9573 let n = picker.options.len();
9574 if n == 0 {
9575 return;
9576 }
9577 let idx = self.state.worker_teach_picker_index as i32;
9578 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9579 }
9580
9581 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
9582 let Some(picker) = self.state.worker_teach_picker.clone() else {
9583 anyhow::bail!("teach picker not open");
9584 };
9585 let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
9586 anyhow::bail!("nothing selected");
9587 };
9588 if !opt.level_ok {
9589 anyhow::bail!(
9590 "{} needs level {} (is level {})",
9591 picker.worker_label,
9592 opt.min_level,
9593 opt.worker_level
9594 );
9595 }
9596 if !opt.can_afford {
9597 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
9598 }
9599 let Some(worker) = self
9600 .state
9601 .hired_workers
9602 .iter()
9603 .find(|w| w.instance_id == picker.worker_instance_id)
9604 .cloned()
9605 else {
9606 anyhow::bail!("worker gone");
9607 };
9608 let (px, py, _) = self.state.player_position_with_z();
9609 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9610 if dist > WORKER_GIVE_RANGE_M {
9611 anyhow::bail!("worker {} too far — stand next to them", worker.label);
9612 }
9613 self.seq += 1;
9614 self.session
9615 .submit_intent(Intent::TeachWorkerBlueprint {
9616 entity_id: self.state.entity_id,
9617 worker_instance_id: picker.worker_instance_id.clone(),
9618 blueprint_id: opt.blueprint_id.clone(),
9619 seq: self.seq,
9620 })
9621 .await?;
9622 self.state.intents_sent += 1;
9623 self.state.push_log(format!(
9624 "Teaching {} to {} ({} cp)",
9625 opt.label, picker.worker_label, opt.cost_copper
9626 ));
9627 self.close_worker_teach_picker();
9628 Ok(())
9629 }
9630
9631 async fn give_item_to_worker(
9632 &mut self,
9633 worker_instance_id: &str,
9634 worker_label: &str,
9635 worker_x: f32,
9636 worker_y: f32,
9637 item_instance_id: uuid::Uuid,
9638 item_label: &str,
9639 quantity: Option<u32>,
9640 ) -> anyhow::Result<()> {
9641 let (px, py, _) = self.state.player_position_with_z();
9642 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
9643 if dist > WORKER_GIVE_RANGE_M {
9644 anyhow::bail!("worker {worker_label} too far — stand next to them");
9645 }
9646 self.seq += 1;
9647 self.session
9648 .submit_intent(Intent::GiveWorkerItem {
9649 entity_id: self.state.entity_id,
9650 worker_instance_id: worker_instance_id.to_string(),
9651 item_instance_id,
9652 quantity,
9653 seq: self.seq,
9654 })
9655 .await?;
9656 self.state.intents_sent += 1;
9657 self.state
9658 .push_log(format!("Gave {item_label} to {worker_label}"));
9659 Ok(())
9660 }
9661
9662 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
9664 let Some(worker) = self
9665 .state
9666 .hired_workers
9667 .get(self.state.workers_menu_index)
9668 .cloned()
9669 else {
9670 anyhow::bail!("select a hired worker first");
9671 };
9672 let (px, py, _) = self.state.player_position_with_z();
9673 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9674 if dist > WORKER_GIVE_RANGE_M {
9675 anyhow::bail!(
9676 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
9677 worker.label
9678 );
9679 }
9680 let options = Self::worker_inventory_options(&worker);
9681 if options.is_empty() {
9682 anyhow::bail!("{} isn't carrying anything", worker.label);
9683 }
9684 let initial_qty = options
9685 .first()
9686 .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
9687 .unwrap_or(1);
9688 self.state.worker_take_picker = Some(WorkerTakePicker {
9689 worker_instance_id: worker.instance_id,
9690 worker_label: worker.label,
9691 options,
9692 quantity: initial_qty,
9693 });
9694 self.state.worker_take_picker_index = 0;
9695 self.state.show_worker_take_picker = true;
9696 Ok(())
9697 }
9698
9699 fn worker_inventory_options(
9700 worker: &flatland_protocol::HiredWorkerView,
9701 ) -> Vec<WorkerGiveOption> {
9702 worker
9703 .inventory
9704 .iter()
9705 .filter_map(|stack| {
9706 let item_instance_id = stack.item_instance_id?;
9707 let label = stack
9708 .display_name
9709 .clone()
9710 .unwrap_or_else(|| stack.template_id.clone());
9711 let label = if stack.quantity > 1 {
9712 format!("{label} ×{}", stack.quantity)
9713 } else {
9714 label
9715 };
9716 Some(WorkerGiveOption {
9717 item_instance_id,
9718 label,
9719 quantity: stack.quantity,
9720 template_id: stack.template_id.clone(),
9721 })
9722 })
9723 .collect()
9724 }
9725
9726 pub fn close_worker_take_picker(&mut self) {
9727 self.state.show_worker_take_picker = false;
9728 self.state.worker_take_picker = None;
9729 self.state.worker_take_picker_index = 0;
9730 }
9731
9732 pub fn worker_take_picker_move(&mut self, delta: i32) {
9733 let Some(picker) = &self.state.worker_take_picker else {
9734 return;
9735 };
9736 let n = picker.options.len();
9737 if n == 0 {
9738 return;
9739 }
9740 let idx = self.state.worker_take_picker_index as i32;
9741 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9742 self.clamp_worker_take_quantity();
9743 }
9744
9745 pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
9746 let Some(picker) = &mut self.state.worker_take_picker else {
9747 return;
9748 };
9749 let max = picker
9750 .options
9751 .get(self.state.worker_take_picker_index)
9752 .map(|o| o.quantity.max(1))
9753 .unwrap_or(1);
9754 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
9755 picker.quantity = next as u32;
9756 }
9757
9758 pub fn worker_take_picker_set_quantity_max(&mut self) {
9759 let Some(picker) = &mut self.state.worker_take_picker else {
9760 return;
9761 };
9762 let max = picker
9763 .options
9764 .get(self.state.worker_take_picker_index)
9765 .map(|o| o.quantity.max(1))
9766 .unwrap_or(1);
9767 picker.quantity = max;
9768 }
9769
9770 fn clamp_worker_take_quantity(&mut self) {
9771 let Some(picker) = &mut self.state.worker_take_picker else {
9772 return;
9773 };
9774 let max = picker
9775 .options
9776 .get(self.state.worker_take_picker_index)
9777 .map(|o| o.quantity.max(1))
9778 .unwrap_or(1);
9779 if picker.quantity == 0 || picker.quantity > max {
9780 picker.quantity = if max > 1 { 1 } else { max };
9781 }
9782 }
9783
9784 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
9785 let Some(picker) = self.state.worker_take_picker.clone() else {
9786 anyhow::bail!("take picker not open");
9787 };
9788 let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
9789 anyhow::bail!("no item selected");
9790 };
9791 let Some(worker) = self
9792 .state
9793 .hired_workers
9794 .iter()
9795 .find(|w| w.instance_id == picker.worker_instance_id)
9796 .cloned()
9797 else {
9798 self.close_worker_take_picker();
9799 anyhow::bail!("worker no longer hired");
9800 };
9801 let qty = picker.quantity.clamp(1, opt.quantity.max(1));
9802 let intent_qty = if qty >= opt.quantity {
9803 None
9804 } else {
9805 Some(qty)
9806 };
9807 self.take_item_from_worker(
9808 &worker.instance_id,
9809 &worker.label,
9810 worker.x,
9811 worker.y,
9812 opt.item_instance_id,
9813 &opt.label,
9814 intent_qty,
9815 )
9816 .await?;
9817 Ok(())
9820 }
9821
9822 async fn take_item_from_worker(
9823 &mut self,
9824 worker_instance_id: &str,
9825 worker_label: &str,
9826 worker_x: f32,
9827 worker_y: f32,
9828 item_instance_id: uuid::Uuid,
9829 item_label: &str,
9830 quantity: Option<u32>,
9831 ) -> anyhow::Result<()> {
9832 let (px, py, _) = self.state.player_position_with_z();
9833 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
9834 if dist > WORKER_GIVE_RANGE_M {
9835 anyhow::bail!("worker {worker_label} too far — stand next to them");
9836 }
9837 self.seq += 1;
9838 self.session
9839 .submit_intent(Intent::TakeWorkerItem {
9840 entity_id: self.state.entity_id,
9841 worker_instance_id: worker_instance_id.to_string(),
9842 item_instance_id,
9843 quantity,
9844 seq: self.seq,
9845 })
9846 .await?;
9847 self.state.intents_sent += 1;
9848 let qty_note = quantity
9849 .map(|q| format!(" ×{q}"))
9850 .unwrap_or_default();
9851 self.state
9852 .push_log(format!("Taking {item_label}{qty_note} from {worker_label}…"));
9853 Ok(())
9854 }
9855
9856 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
9857 if !self.state.has_worker_lodging() {
9858 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
9859 }
9860 self.seq += 1;
9861 self.session
9862 .submit_intent(Intent::HireWorker {
9863 entity_id: self.state.entity_id,
9864 def_id: "worker_laborer".into(),
9865 wage_copper_per_interval: 8,
9866 lodging_container_id: None,
9867 job_yaml: None,
9868 seq: self.seq,
9869 })
9870 .await?;
9871 self.state.intents_sent += 1;
9872 Ok(())
9873 }
9874
9875 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
9876 let Some(worker) = self
9877 .state
9878 .hired_workers
9879 .get(self.state.workers_menu_index)
9880 .cloned()
9881 else {
9882 anyhow::bail!("select a hired worker first");
9883 };
9884 let lodging = worker.lodging_container_id.clone().or_else(|| {
9885 crate::worker_route_editor::owned_lodging_container_ids(
9886 &self.state.placed_containers,
9887 self.state.character_id,
9888 )
9889 .into_iter()
9890 .next()
9891 .map(|(id, _)| id)
9892 });
9893 let label = worker.label.clone();
9894 let editor = if let Some(route) = &worker.route {
9895 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
9896 worker.instance_id,
9897 worker.label,
9898 route,
9899 lodging,
9900 )
9901 } else {
9902 crate::worker_route_editor::WorkerRouteEditorState::new(
9903 worker.instance_id,
9904 worker.label,
9905 lodging,
9906 )
9907 };
9908 self.state.worker_route_editor = Some(editor);
9909 if let Some(ed) = self.state.worker_route_editor.as_mut() {
9910 if let Some(collapsed) =
9911 crate::client_config::ClientConfig::load().worker_route_panel_collapsed
9912 {
9913 ed.panel_collapsed = collapsed;
9914 }
9915 }
9916 self.state.show_workers_menu = false;
9917 self.state.push_log(format!(
9918 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
9919 ));
9920 Ok(())
9921 }
9922
9923 pub fn close_worker_route_editor(&mut self) {
9924 self.state.worker_route_editor = None;
9925 }
9926
9927 pub fn worker_route_editor_toggle_panel(&mut self) {
9928 if let Some(ed) = self.state.worker_route_editor.as_mut() {
9929 ed.toggle_panel_collapsed();
9930 let collapsed = ed.panel_collapsed;
9931 let mut cfg = crate::client_config::ClientConfig::load();
9932 let _ = cfg.save_worker_route_panel_collapsed(collapsed);
9933 }
9934 }
9935
9936 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
9937 let n = {
9938 let Some(ed) = self.state.worker_route_editor.as_mut() else {
9939 return;
9940 };
9941 ed.append_waypoint(x, y, z);
9942 ed.stop_count()
9943 };
9944 self.state
9945 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
9946 }
9947
9948 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
9951 let (px, py, _) = self.state.player_position_with_z();
9952 crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
9953 &self.state.placed_containers,
9954 &self.state.buildings,
9955 self.state.character_id,
9956 px,
9957 py,
9958 &self.state.hired_workers,
9959 )
9960 }
9961
9962 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
9963 self.state.route_editor_node_candidates()
9964 }
9965
9966 fn re_open_harvest_picker(
9967 &mut self,
9968 index: usize,
9969 picked: std::collections::BTreeSet<String>,
9970 ) {
9971 use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
9972 let nodes = self.state.route_editor_node_candidates();
9973 let index = if nodes.is_empty() {
9974 ROUTE_PICKER_DONE_ROW
9975 } else {
9976 index.max(1).min(nodes.len())
9977 };
9978 self.re_open_sheet(S::HarvestPicker {
9979 index,
9980 picked,
9981 nodes,
9982 });
9983 }
9984
9985 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
9986 let (px, py, _) = self.state.player_position_with_z();
9987 crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
9988 }
9989
9990 fn re_template_candidates(&self) -> Vec<String> {
9991 let mut extra = Vec::new();
9992 if let Some(ed) = self.state.worker_route_editor.as_ref() {
9993 for stop in &ed.stops {
9994 match stop {
9995 crate::worker_route_editor::WorkerRouteStop::DepositAt {
9996 filter: Some(filter),
9997 ..
9998 } => extra.extend(filter.iter().cloned()),
9999 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
10000 extra.push(template.clone());
10001 }
10002 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
10003 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
10004 extra.push(bp.output.clone());
10005 for input in &bp.inputs {
10006 extra.push(input.template_id.clone());
10007 }
10008 }
10009 }
10010 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
10011 for it in items {
10012 extra.push(it.template.clone());
10013 }
10014 }
10015 _ => {}
10016 }
10017 }
10018 if let Some(worker) = self
10020 .state
10021 .hired_workers
10022 .iter()
10023 .find(|w| w.instance_id == ed.worker_instance_id)
10024 {
10025 for recipe in &worker.known_blueprint_ids {
10026 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
10027 extra.push(bp.output.clone());
10028 }
10029 }
10030 }
10031 }
10032 crate::worker_route_editor::route_item_template_candidates(
10033 &self.state.placed_containers,
10034 self.state.character_id,
10035 &self.state.inventory,
10036 &self.state.blueprints,
10037 &self.state.resource_nodes,
10038 &extra,
10039 )
10040 }
10041
10042 fn re_blueprint_ids(&self) -> Vec<String> {
10043 let worker_known: Option<&[String]> = self
10044 .state
10045 .worker_route_editor
10046 .as_ref()
10047 .and_then(|ed| {
10048 self.state
10049 .hired_workers
10050 .iter()
10051 .find(|w| w.instance_id == ed.worker_instance_id)
10052 })
10053 .map(|w| w.known_blueprint_ids.as_slice());
10054 crate::worker_route_editor::worker_craft_blueprint_ids(
10055 &self.state.blueprints,
10056 worker_known,
10057 )
10058 }
10059
10060 fn re_bed_candidates(&self) -> Vec<(String, String)> {
10061 crate::worker_route_editor::owned_lodging_container_ids(
10062 &self.state.placed_containers,
10063 self.state.character_id,
10064 )
10065 }
10066
10067 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
10068 self.state
10069 .placed_containers
10070 .iter()
10071 .find(|c| c.id == container_id)
10072 .map(|c| c.contents.clone())
10073 .unwrap_or_default()
10074 }
10075
10076 fn re_sheet_supports_filter(&self) -> bool {
10079 use crate::worker_route_editor::RouteEditorSheet as S;
10080 self.state
10081 .worker_route_editor
10082 .as_ref()
10083 .is_some_and(|ed| {
10084 matches!(
10085 ed.sheet,
10086 S::HarvestPicker { .. }
10087 | S::SellItem { .. }
10088 | S::DepositFilter { .. }
10089 | S::WithdrawItems { .. }
10090 | S::WithdrawContainers { .. }
10091 | S::DepositContainers { .. }
10092 | S::SellNpcs { .. }
10093 | S::CraftBlueprint { .. }
10094 | S::BedPicker { .. }
10095 )
10096 })
10097 }
10098
10099 pub fn re_sheet_row_visible(&self, row: usize) -> bool {
10101 use crate::worker_route_editor::{
10102 harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
10103 ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
10104 };
10105 let Some(ed) = self.state.worker_route_editor.as_ref() else {
10106 return false;
10107 };
10108 let filter = &ed.sheet_filter;
10109 match &ed.sheet {
10110 S::HarvestPicker { nodes, .. } => {
10111 harvest_picker_row_matches(nodes, row, filter)
10112 }
10113 S::SellItem { templates, .. } => {
10114 if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
10115 return true;
10116 }
10117 let slot = row.saturating_sub(2);
10118 templates.get(slot).is_some_and(|t| {
10119 let label = self.state.template_display_name(t);
10120 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10121 })
10122 }
10123 S::DepositFilter { rows, .. } => {
10124 if row >= rows.len() {
10125 return true;
10126 }
10127 rows.get(row).is_some_and(|(t, _)| {
10128 let label = self.state.template_display_name(t);
10129 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10130 })
10131 }
10132 S::WithdrawItems { lines, .. } => {
10133 if row >= lines.len() {
10134 return true;
10135 }
10136 lines.get(row).is_some_and(|l| {
10137 let label = self.state.template_display_name(&l.template);
10138 list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
10139 })
10140 }
10141 S::WithdrawContainers { .. } | S::DepositContainers { .. } => self
10142 .re_container_candidates()
10143 .get(row)
10144 .is_some_and(|c| {
10145 list_filter_row_matches(
10146 filter,
10147 Some(c.dist),
10148 &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
10149 )
10150 }),
10151 S::SellNpcs { .. } => {
10152 if row == 0 {
10153 return true;
10154 }
10155 self.re_npc_candidates().get(row - 1).is_some_and(|n| {
10156 list_filter_row_matches(filter, Some(n.dist), &[n.label.as_str(), n.id.as_str()])
10157 })
10158 }
10159 S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
10160 let label = self
10161 .state
10162 .blueprints
10163 .iter()
10164 .find(|b| &b.id == id)
10165 .map(|b| {
10166 if b.label.is_empty() {
10167 id.as_str()
10168 } else {
10169 b.label.as_str()
10170 }
10171 })
10172 .unwrap_or(id.as_str());
10173 list_filter_row_matches(filter, None, &[id.as_str(), label])
10174 }),
10175 S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
10176 list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
10177 }),
10178 _ => true,
10179 }
10180 }
10181
10182 fn re_sheet_clamp_index(&mut self) {
10183 let count = self.re_sheet_row_count();
10184 if count == 0 {
10185 return;
10186 }
10187 let cur = self.re_sheet_index();
10188 if self.re_sheet_row_visible(cur) {
10189 return;
10190 }
10191 for offset in 1..count {
10192 if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
10193 self.re_sheet_set_index(cur + offset);
10194 return;
10195 }
10196 if cur >= offset && self.re_sheet_row_visible(cur - offset) {
10197 self.re_sheet_set_index(cur - offset);
10198 return;
10199 }
10200 }
10201 }
10202
10203 fn re_sheet_set_index(&mut self, index: usize) {
10204 use crate::worker_route_editor::RouteEditorSheet as S;
10205 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10206 return;
10207 };
10208 match &mut ed.sheet {
10209 S::AddMenu { index: slot }
10210 | S::WaypointMenu { index: slot }
10211 | S::HarvestPicker { index: slot, .. }
10212 | S::WithdrawContainers { index: slot }
10213 | S::DepositContainers { index: slot }
10214 | S::SellNpcs { index: slot }
10215 | S::CraftBlueprint { index: slot }
10216 | S::BedPicker { index: slot }
10217 | S::FarmPlotPicker { index: slot, .. }
10218 | S::FarmPlantSeed { index: slot, .. }
10219 | S::WithdrawItems { index: slot, .. }
10220 | S::DepositFilter { index: slot, .. }
10221 | S::SellItem { index: slot, .. } => *slot = index,
10222 _ => {}
10223 }
10224 }
10225
10226 pub fn re_focus_sheet_filter(&mut self) {
10227 if !self.re_sheet_supports_filter() {
10228 return;
10229 }
10230 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10231 ed.sheet_filter_focused = true;
10232 }
10233 }
10234
10235 pub fn re_blur_sheet_filter_keep_text(&mut self) {
10236 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10237 return;
10238 };
10239 if !ed.sheet_filter_focused {
10240 return;
10241 }
10242 ed.sheet_filter_focused = false;
10243 self.re_sheet_clamp_index();
10244 }
10245
10246 pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
10247 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10248 return false;
10249 };
10250 if ed.sheet_filter_focused {
10251 ed.sheet_filter_focused = false;
10252 self.re_sheet_clamp_index();
10253 return true;
10254 }
10255 if !ed.sheet_filter.is_empty() {
10256 ed.sheet_filter.clear();
10257 self.re_sheet_clamp_index();
10258 return true;
10259 }
10260 false
10261 }
10262
10263 pub fn re_append_sheet_filter_char(&mut self, ch: char) {
10264 if ch.is_control() {
10265 return;
10266 }
10267 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10268 return;
10269 };
10270 if !ed.sheet_filter_focused {
10271 return;
10272 }
10273 ed.sheet_filter.push(ch);
10274 self.re_sheet_set_index(0);
10275 self.re_sheet_clamp_index();
10276 }
10277
10278 pub fn re_sheet_filter_backspace(&mut self) {
10279 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10280 return;
10281 };
10282 if !ed.sheet_filter_focused {
10283 return;
10284 }
10285 ed.sheet_filter.pop();
10286 self.re_sheet_set_index(0);
10287 self.re_sheet_clamp_index();
10288 }
10289
10290 pub fn re_sheet_row_count(&self) -> usize {
10292 use crate::worker_route_editor::{
10293 harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
10294 };
10295 let Some(ed) = self.state.worker_route_editor.as_ref() else {
10296 return 0;
10297 };
10298 match &ed.sheet {
10299 S::Stops => ed.stops.len(),
10300 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
10301 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
10302 S::WaypointMapPick => 0,
10303 S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
10304 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
10305 self.re_container_candidates().len()
10306 }
10307 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()),
10311 S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
10312 S::WaitEntry { .. } => 1,
10313 S::BedPicker { .. } => self.re_bed_candidates().len(),
10314 S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
10315 S::FarmPlantSeed { seeds, .. } => seeds.len(),
10316 }
10317 }
10318
10319 pub fn re_sheet_index(&self) -> usize {
10321 use crate::worker_route_editor::RouteEditorSheet as S;
10322 let Some(ed) = self.state.worker_route_editor.as_ref() else {
10323 return 0;
10324 };
10325 match &ed.sheet {
10326 S::AddMenu { index }
10327 | S::WaypointMenu { index }
10328 | S::HarvestPicker { index, .. }
10329 | S::WithdrawContainers { index }
10330 | S::DepositContainers { index }
10331 | S::SellNpcs { index }
10332 | S::CraftBlueprint { index }
10333 | S::BedPicker { index }
10334 | S::FarmPlotPicker { index, .. }
10335 | S::FarmPlantSeed { index, .. }
10336 | S::WithdrawItems { index, .. }
10337 | S::DepositFilter { index, .. }
10338 | S::SellItem { index, .. } => *index,
10339 _ => 0,
10340 }
10341 }
10342
10343 pub fn re_sheet_move(&mut self, delta: i32) {
10345 let count = self.re_sheet_row_count();
10346 if count == 0 {
10347 return;
10348 }
10349 let cur = self.re_sheet_index();
10350 let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
10351 self.re_sheet_set_index(next);
10352 }
10353
10354 pub fn re_sheet_page(&mut self, pages: i32) {
10355 let count = self.re_sheet_row_count();
10356 if count == 0 {
10357 return;
10358 }
10359 let cur = self.re_sheet_index();
10360 let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
10361 self.re_sheet_set_index(next);
10362 }
10363
10364 pub fn re_sheet_adjust(&mut self, delta: i32) {
10366 use crate::worker_route_editor::RouteEditorSheet as S;
10367 let index = self.re_sheet_index();
10368 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10369 return;
10370 };
10371 match &mut ed.sheet {
10372 S::WithdrawItems { lines, .. } => {
10373 if let Some(line) = lines.get_mut(index) {
10374 line.adjust_qty(delta);
10375 }
10376 }
10377 S::WaitEntry { ticks } => {
10378 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
10379 }
10380 _ => {}
10381 }
10382 }
10383
10384 pub fn re_sheet_back(&mut self) {
10385 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10386 return;
10387 };
10388 use crate::worker_route_editor::RouteEditorSheet as S;
10389 let was_editing = ed.editing_index.is_some();
10390 let from_top_picker = matches!(
10391 ed.sheet,
10392 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
10393 );
10394 ed.sheet_back();
10395 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
10396 self.state
10398 .push_log("Route: left edit sheet — press s to save current stops".to_string());
10399 }
10400 }
10401
10402 pub fn re_at_root_sheet(&self) -> bool {
10404 self.state
10405 .worker_route_editor
10406 .as_ref()
10407 .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
10408 }
10409
10410 pub fn re_open_add_menu(&mut self) {
10411 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10412 ed.open_add_menu();
10413 }
10414 }
10415
10416 pub fn re_open_bed_picker(&mut self) {
10417 let beds = self.re_bed_candidates();
10418 if beds.is_empty() {
10419 self.state
10420 .push_log("Route: place a camp bed first".to_string());
10421 return;
10422 }
10423 let current = self
10424 .state
10425 .worker_route_editor
10426 .as_ref()
10427 .and_then(|ed| ed.lodging_container_id.clone());
10428 let index = current
10429 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
10430 .unwrap_or(0);
10431 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
10432 }
10433
10434 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
10435 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10436 ed.open_sheet(sheet);
10437 }
10438 }
10439
10440 fn re_confirm_stop(
10442 &mut self,
10443 stop: crate::worker_route_editor::WorkerRouteStop,
10444 what: String,
10445 ) {
10446 let appended = self
10447 .state
10448 .worker_route_editor
10449 .as_mut()
10450 .is_some_and(|ed| ed.confirm_stop(stop));
10451 if appended {
10452 self.state.push_log(format!("Route: + {what}"));
10453 } else {
10454 self.state
10455 .push_log(format!("Route: {what} already in route — selected it"));
10456 }
10457 }
10458
10459 fn re_open_withdraw_items(&mut self, container_id: String) {
10460 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
10461 let contents = self.re_container_contents(&container_id);
10462 let existing = self
10466 .state
10467 .worker_route_editor
10468 .as_ref()
10469 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10470 .and_then(|stop| match stop {
10471 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
10472 _ => None,
10473 })
10474 .unwrap_or_default();
10475 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
10476 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10479 let _ = ed.retarget_withdraw_container(container_id.clone());
10480 }
10481 self.re_open_sheet(S::WithdrawItems {
10482 container_id,
10483 lines,
10484 index: 0,
10485 });
10486 }
10487
10488 fn re_withdraw_items_activate(&mut self, index: usize) {
10489 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
10490 enum Outcome {
10491 Cycled,
10492 Confirmed(String),
10493 Empty,
10494 }
10495 let outcome = {
10496 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10497 return;
10498 };
10499 let S::WithdrawItems {
10500 container_id,
10501 lines,
10502 index: sheet_index,
10503 } = &mut ed.sheet
10504 else {
10505 return;
10506 };
10507 *sheet_index = index;
10508 if index < lines.len() {
10509 lines[index].cycle();
10510 Outcome::Cycled
10511 } else {
10512 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
10513 if items.is_empty() {
10514 Outcome::Empty
10515 } else {
10516 let stop = WorkerRouteStop::WithdrawFrom {
10517 container_id: container_id.clone(),
10518 items,
10519 };
10520 let summary = stop.summary();
10521 ed.confirm_stop(stop);
10522 Outcome::Confirmed(summary)
10523 }
10524 }
10525 };
10526 match outcome {
10527 Outcome::Cycled => {}
10528 Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
10529 Outcome::Empty => self
10530 .state
10531 .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
10532 }
10533 }
10534
10535 fn re_open_deposit_filter(&mut self, container_id: String) {
10536 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10537 let existing_filter = self
10539 .state
10540 .worker_route_editor
10541 .as_ref()
10542 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10543 .and_then(|stop| match stop {
10544 WorkerRouteStop::DepositAt { filter, .. } => {
10545 Some(filter.clone().unwrap_or_default())
10546 }
10547 _ => None,
10548 });
10549 let mut candidates = self.re_template_candidates();
10550 if let Some(ref chosen) = existing_filter {
10551 for t in chosen {
10552 if !candidates.iter().any(|c| c == t) {
10553 candidates.push(t.clone());
10554 }
10555 }
10556 candidates.sort();
10557 candidates.dedup();
10558 }
10559 let rows: Vec<(String, bool)> = match existing_filter {
10560 Some(chosen) => candidates
10561 .iter()
10562 .map(|t| (t.clone(), chosen.contains(t)))
10563 .collect(),
10564 None => candidates.into_iter().map(|t| (t, false)).collect(),
10565 };
10566 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10567 let _ = ed.retarget_deposit_container(container_id.clone());
10568 }
10569 self.re_open_sheet(S::DepositFilter {
10570 container_id,
10571 rows,
10572 index: 0,
10573 });
10574 }
10575
10576 fn re_deposit_filter_activate(&mut self, index: usize) {
10577 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10578 let mut confirmed: Option<String> = None;
10579 {
10580 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10581 return;
10582 };
10583 let S::DepositFilter {
10584 container_id,
10585 rows,
10586 index: sheet_index,
10587 } = &mut ed.sheet
10588 else {
10589 return;
10590 };
10591 *sheet_index = index;
10592 if index < rows.len() {
10593 rows[index].1 = !rows[index].1;
10594 } else {
10595 let chosen: Vec<String> = rows
10597 .iter()
10598 .filter(|(_, on)| *on)
10599 .map(|(t, _)| t.clone())
10600 .collect();
10601 let filter = if chosen.is_empty() { None } else { Some(chosen) };
10602 let stop = WorkerRouteStop::DepositAt {
10603 container_id: container_id.clone(),
10604 filter,
10605 };
10606 confirmed = Some(stop.summary());
10607 ed.confirm_stop(stop);
10608 }
10609 }
10610 if let Some(what) = confirmed {
10611 self.state.push_log(format!("Route: + {what}"));
10612 }
10613 }
10614
10615 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
10616 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10617 let templates = self.re_template_candidates();
10618 if templates.is_empty() {
10619 self.state.push_log(
10620 "Route: no item templates available — learn a craft recipe or place a harvest node first"
10621 .to_string(),
10622 );
10623 return;
10624 }
10625 let (pre_npc, pre_template, pre_all) = self
10627 .state
10628 .worker_route_editor
10629 .as_ref()
10630 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10631 .and_then(|stop| match stop {
10632 WorkerRouteStop::TradeWith {
10633 npc_id,
10634 template,
10635 sell_all,
10636 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
10637 _ => None,
10638 })
10639 .unwrap_or((None, None, true));
10640 let npc_id = npc_id.or(pre_npc);
10641 let mut picked = std::collections::BTreeSet::new();
10642 if let Some(t) = pre_template {
10643 picked.insert(t);
10644 }
10645 self.re_open_sheet(S::SellItem {
10646 npc_id,
10647 templates,
10648 index: if picked.is_empty() {
10649 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
10650 } else {
10651 2
10652 },
10653 sell_all: pre_all,
10654 picked,
10655 });
10656 }
10657
10658 fn re_sell_item_activate(&mut self, index: usize) {
10659 use crate::worker_route_editor::{
10660 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
10661 };
10662 let mut batch_log: Option<String> = None;
10663 {
10664 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10665 return;
10666 };
10667 let S::SellItem {
10668 npc_id,
10669 templates,
10670 index: sheet_index,
10671 sell_all,
10672 picked,
10673 } = &mut ed.sheet
10674 else {
10675 return;
10676 };
10677 *sheet_index = index;
10678 if index == ROUTE_PICKER_DONE_ROW {
10679 if picked.is_empty() {
10680 batch_log = Some(
10681 "Route: pick at least one item (Space toggles, Done confirms)".into(),
10682 );
10683 } else {
10684 let picks: Vec<String> = picked.iter().cloned().collect();
10685 let npc = npc_id.clone();
10686 let all = *sell_all;
10687 let added = ed.confirm_trade_picks(npc, &picks, all);
10688 batch_log = Some(format!("Route: + {added} sell stop(s)"));
10689 }
10690 } else if index == SELL_ITEM_TOGGLE_ROW {
10691 *sell_all = !*sell_all;
10692 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
10693 if picked.contains(template) {
10694 picked.remove(template);
10695 } else {
10696 picked.insert(template.clone());
10697 }
10698 }
10699 }
10700 if let Some(msg) = batch_log {
10701 self.state.push_log(msg);
10702 }
10703 }
10704
10705 pub fn re_edit_selected_stop(&mut self) {
10707 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10708 let Some(stop) = self
10709 .state
10710 .worker_route_editor
10711 .as_ref()
10712 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
10713 else {
10714 self.state
10715 .push_log("Route: no stop selected — press a to add one".to_string());
10716 return;
10717 };
10718 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10719 ed.begin_edit_selected();
10720 }
10721 match stop {
10722 WorkerRouteStop::Waypoint { .. } => {
10723 self.re_open_sheet(S::WaypointMenu { index: 0 });
10724 }
10725 WorkerRouteStop::HarvestNode { node_id } => {
10726 let nodes = self.state.route_editor_node_candidates();
10727 if nodes.is_empty() {
10728 self.re_cancel_edit();
10729 self.state
10730 .push_log("Route: no harvestable nodes visible to retarget".to_string());
10731 } else {
10732 let mut picked = std::collections::BTreeSet::new();
10733 picked.insert(node_id.clone());
10734 let index = nodes
10735 .iter()
10736 .position(|n| n.id == node_id)
10737 .map(|i| i + 1)
10738 .unwrap_or(1);
10739 self.re_open_harvest_picker(index, picked);
10740 }
10741 }
10742 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
10743 let containers = self.re_container_candidates();
10746 if containers.is_empty() {
10747 self.re_cancel_edit();
10748 self.state
10749 .push_log("Route: place a storage chest first".to_string());
10750 } else {
10751 let index = containers
10752 .iter()
10753 .position(|c| c.id == container_id)
10754 .unwrap_or(0);
10755 self.re_open_sheet(S::WithdrawContainers { index });
10756 }
10757 }
10758 WorkerRouteStop::DepositAt { container_id, .. } => {
10759 let containers = self.re_container_candidates();
10760 if containers.is_empty() {
10761 self.re_cancel_edit();
10762 self.state
10763 .push_log("Route: place a storage chest first".to_string());
10764 } else {
10765 let index = containers
10766 .iter()
10767 .position(|c| c.id == container_id)
10768 .unwrap_or(0);
10769 self.re_open_sheet(S::DepositContainers { index });
10770 }
10771 }
10772 WorkerRouteStop::TradeWith { npc_id, .. } => {
10773 let npcs = self.re_npc_candidates();
10774 let index = npc_id
10776 .as_ref()
10777 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
10778 .unwrap_or(0);
10779 self.re_open_sheet(S::SellNpcs { index });
10780 }
10781 WorkerRouteStop::CraftAt { blueprint, .. } => {
10782 let bps = self.re_blueprint_ids();
10783 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
10784 if bps.is_empty() {
10785 self.re_cancel_edit();
10786 self.state
10787 .push_log("Route: no known blueprints to retarget".to_string());
10788 } else {
10789 self.re_open_sheet(S::CraftBlueprint { index });
10790 }
10791 }
10792 WorkerRouteStop::CultivatePlot { .. } => {
10793 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate);
10794 }
10795 WorkerRouteStop::PlantPlot { .. } => {
10796 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
10797 }
10798 WorkerRouteStop::HarvestPlot { .. } => {
10799 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
10800 }
10801 WorkerRouteStop::RestIfNeeded => {
10802 self.re_cancel_edit();
10803 self.state
10804 .push_log("Route: rest has no settings (change the bed with l)".to_string());
10805 }
10806 WorkerRouteStop::Wait { wait_ticks } => {
10807 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
10808 }
10809 }
10810 }
10811
10812 fn re_cancel_edit(&mut self) {
10813 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10814 ed.editing_index = None;
10815 }
10816 }
10817
10818 pub fn worker_route_editor_ui_click(
10821 &mut self,
10822 click: crate::worker_route_editor::RouteEditorClick,
10823 ) {
10824 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
10825 match click {
10826 RouteEditorClick::SelectStop(i) => {
10827 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10828 ed.sheet = S::Stops;
10829 ed.select_stop(i);
10830 }
10831 }
10832 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
10833 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
10834 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
10835 }
10836 }
10837
10838 pub fn re_sheet_row_activate(&mut self, row: usize) {
10840 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10841 let Some(sheet) = self
10842 .state
10843 .worker_route_editor
10844 .as_ref()
10845 .map(|ed| ed.sheet.clone())
10846 else {
10847 return;
10848 };
10849 match sheet {
10850 S::Stops => {
10851 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10852 ed.select_stop(row);
10853 }
10854 }
10855 S::AddMenu { .. } => match row {
10856 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
10857 1 => {
10858 if self.re_node_candidates().is_empty() {
10859 self.state
10860 .push_log("Route: no harvestable nodes visible in this region".to_string());
10861 } else {
10862 self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
10863 }
10864 }
10865 2 | 3 => {
10866 if self.re_container_candidates().is_empty() {
10867 self.state
10868 .push_log("Route: place a storage chest first".to_string());
10869 } else if row == 2 {
10870 self.re_open_sheet(S::WithdrawContainers { index: 0 });
10871 } else {
10872 self.re_open_sheet(S::DepositContainers { index: 0 });
10873 }
10874 }
10875 4 => {
10876 if self.re_template_candidates().is_empty() {
10877 self.state.push_log(
10878 "Route: no item templates available — learn a craft recipe or place a harvest node first"
10879 .to_string(),
10880 );
10881 } else {
10882 self.re_open_sheet(S::SellNpcs { index: 0 });
10883 }
10884 }
10885 5 => {
10886 if self.re_blueprint_ids().is_empty() {
10887 self.state.push_log(
10888 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
10889 .to_string(),
10890 );
10891 } else {
10892 self.re_open_sheet(S::CraftBlueprint { index: 0 });
10893 }
10894 }
10895 6 => self.re_confirm_stop(
10896 WorkerRouteStop::RestIfNeeded,
10897 "rest at lodging (if needed)".into(),
10898 ),
10899 7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
10900 8 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate),
10901 9 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant),
10902 10 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
10903 _ => {}
10904 },
10905 S::WaypointMenu { .. } => match row {
10906 0 => {
10907 let (x, y, z) = self.state.player_position_with_z();
10908 let stop = WorkerRouteStop::Waypoint { x, y, z };
10909 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
10910 }
10911 1 => {
10912 self.re_open_sheet(S::WaypointMapPick);
10913 self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
10914 }
10915 _ => {}
10916 },
10917 S::HarvestPicker { .. } => {
10918 let mut log: Option<String> = None;
10919 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10920 let S::HarvestPicker {
10921 index: sheet_index,
10922 picked,
10923 nodes,
10924 } = &mut ed.sheet
10925 else {
10926 return;
10927 };
10928 *sheet_index = row;
10929 if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
10930 if picked.is_empty() {
10931 log = Some(
10932 "Route: pick at least one node (Space toggles, Done confirms)"
10933 .into(),
10934 );
10935 } else {
10936 let ids: Vec<String> = picked.iter().cloned().collect();
10937 let added = ed.confirm_harvest_picks(&ids);
10938 log = Some(format!("Route: + {added} harvest stop(s)"));
10939 }
10940 } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
10941 if picked.contains(&n.id) {
10942 picked.remove(&n.id);
10943 } else {
10944 picked.insert(n.id.clone());
10945 }
10946 }
10947 }
10948 if let Some(msg) = log {
10949 self.state.push_log(msg);
10950 }
10951 }
10952 S::WithdrawContainers { .. } => {
10953 let containers = self.re_container_candidates();
10954 if let Some(c) = containers.get(row) {
10955 let id = c.id.clone();
10956 self.re_open_withdraw_items(id);
10957 }
10958 }
10959 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
10960 S::DepositContainers { .. } => {
10961 let containers = self.re_container_candidates();
10962 if let Some(c) = containers.get(row) {
10963 let id = c.id.clone();
10964 self.re_open_deposit_filter(id);
10965 }
10966 }
10967 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
10968 S::SellNpcs { .. } => {
10969 let npcs = self.re_npc_candidates();
10970 let npc_id = if row == 0 {
10971 None
10972 } else {
10973 npcs.get(row - 1).map(|n| n.id.clone())
10974 };
10975 if row == 0 || npc_id.is_some() {
10976 self.re_open_sell_item(npc_id);
10977 }
10978 }
10979 S::SellItem { .. } => self.re_sell_item_activate(row),
10980 S::CraftBlueprint { .. } => {
10981 let bps = self.re_blueprint_ids();
10982 if let Some(bp) = bps.get(row) {
10983 let stop = WorkerRouteStop::CraftAt {
10984 device: "hand".into(),
10985 blueprint: bp.clone(),
10986 qty: None,
10987 };
10988 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
10989 }
10990 }
10991 S::WaitEntry { ticks } => {
10992 let stop = WorkerRouteStop::Wait {
10993 wait_ticks: ticks,
10994 };
10995 self.re_confirm_stop(stop, format!("wait {ticks}t"));
10996 }
10997 S::BedPicker { .. } => {
10998 let beds = self.re_bed_candidates();
10999 if let Some((id, name)) = beds.get(row) {
11000 let (id, name) = (id.clone(), name.clone());
11001 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11002 ed.lodging_container_id = Some(id.clone());
11003 ed.sheet = S::Stops;
11004 }
11005 self.state
11006 .push_log(format!("Route: rest bed set to {name}"));
11007 }
11008 }
11009 S::FarmPlotPicker { action, .. } => {
11010 let plots = self.re_farm_plot_candidates();
11011 let Some(plot) = plots.get(row).cloned() else {
11012 return;
11013 };
11014 match action {
11015 crate::worker_route_editor::FarmPlotAction::Cultivate => {
11016 let label = plot_route_label(&plot);
11017 self.re_confirm_stop(
11018 WorkerRouteStop::CultivatePlot {
11019 plot_id: plot.plot_id,
11020 },
11021 format!("cultivate {label}"),
11022 );
11023 }
11024 crate::worker_route_editor::FarmPlotAction::Harvest => {
11025 let label = plot_route_label(&plot);
11026 self.re_confirm_stop(
11027 WorkerRouteStop::HarvestPlot {
11028 plot_id: plot.plot_id,
11029 },
11030 format!("harvest {label}"),
11031 );
11032 }
11033 crate::worker_route_editor::FarmPlotAction::Plant => {
11034 let seeds = self.re_farm_seed_candidates();
11035 if seeds.is_empty() {
11036 self.state.push_log(
11037 "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
11038 );
11039 return;
11040 }
11041 self.re_open_sheet(S::FarmPlantSeed {
11042 plot_id: plot.plot_id,
11043 seeds,
11044 index: 0,
11045 });
11046 }
11047 }
11048 }
11049 S::FarmPlantSeed { plot_id, seeds, .. } => {
11050 if let Some(seed) = seeds.get(row).cloned() {
11051 self.re_confirm_stop(
11052 WorkerRouteStop::PlantPlot {
11053 plot_id,
11054 seed_template: seed.clone(),
11055 },
11056 format!("plant {seed}"),
11057 );
11058 }
11059 }
11060 S::WaypointMapPick => {}
11061 }
11062 }
11063
11064 fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
11065 use crate::worker_route_editor::RouteEditorSheet as S;
11066 if self.re_farm_plot_candidates().is_empty() {
11067 self.state.push_log(
11068 "Route: no farmable plots visible — claim land or get farm access first",
11069 );
11070 return;
11071 }
11072 self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
11073 }
11074
11075 fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
11076 self.state
11077 .property_plots
11078 .iter()
11079 .filter(|p| p.is_mine || p.may_farm)
11080 .cloned()
11081 .collect()
11082 }
11083
11084 fn re_farm_seed_candidates(&self) -> Vec<String> {
11088 let mut set = std::collections::BTreeSet::new();
11089 let looks_like_seed = |id: &str| {
11090 id.ends_with("_seed") || id == "potato_seed" || id == "carrot_seed"
11091 };
11092 for (id, _, _) in self.state.farm_seed_entries() {
11093 set.insert(id);
11094 }
11095 for c in &self.state.placed_containers {
11096 let mine = match (self.state.character_id, c.owner_character_id) {
11097 (Some(a), Some(b)) => a == b,
11098 _ => false,
11099 };
11100 if !mine {
11101 continue;
11102 }
11103 for s in &c.contents {
11104 if s.quantity > 0
11105 && (s.props.contains_key("seed_for") || looks_like_seed(&s.template_id))
11106 {
11107 set.insert(s.template_id.clone());
11108 }
11109 }
11110 }
11111 if let Some(ed) = self.state.worker_route_editor.as_ref() {
11112 for stop in &ed.stops {
11113 if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } = stop
11114 {
11115 for it in items {
11116 if looks_like_seed(&it.template) {
11117 set.insert(it.template.clone());
11118 }
11119 }
11120 }
11121 if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
11122 seed_template, ..
11123 } = stop
11124 {
11125 if !seed_template.is_empty() {
11126 set.insert(seed_template.clone());
11127 }
11128 }
11129 }
11130 }
11131 for id in self.state.inventory_hints.keys() {
11132 if looks_like_seed(id) {
11133 set.insert(id.clone());
11134 }
11135 }
11136 for id in ["potato_seed", "carrot_seed"] {
11138 set.insert(id.to_string());
11139 }
11140 set.into_iter().collect()
11141 }
11142
11143 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
11150 use crate::worker_route_editor as wre;
11151 use wre::RouteEditorSheet as S;
11152 if self.state.worker_route_editor.is_none() {
11153 return;
11154 }
11155 let sheet = self
11156 .state
11157 .worker_route_editor
11158 .as_ref()
11159 .map(|ed| ed.sheet.clone())
11160 .unwrap_or(S::Stops);
11161 match sheet {
11162 S::WaypointMapPick => {
11163 let (_, _, z) = self.state.player_position_with_z();
11164 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
11165 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
11166 let editing = self
11168 .state
11169 .worker_route_editor
11170 .as_ref()
11171 .is_some_and(|ed| ed.editing_index.is_some());
11172 if !editing {
11173 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11174 ed.sheet = S::WaypointMapPick;
11175 }
11176 }
11177 }
11178 S::HarvestPicker { .. } => {
11179 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11180 let mut log: Option<String> = None;
11181 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11182 let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
11183 return;
11184 };
11185 let selected = if picked.contains(&node.id) {
11186 picked.remove(&node.id);
11187 false
11188 } else {
11189 picked.insert(node.id.clone());
11190 true
11191 };
11192 log = Some(format!(
11193 "Route: {} {}",
11194 if selected { "selected" } else { "deselected" },
11195 node.label
11196 ));
11197 }
11198 if let Some(msg) = log {
11199 self.state.push_log(msg);
11200 }
11201 }
11202 }
11203 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
11204 if let Some(cid) = wre::pick_storage_container_at(
11206 &self.state.placed_containers,
11207 self.state.character_id,
11208 x,
11209 y,
11210 ) {
11211 self.re_open_withdraw_items(cid);
11212 }
11213 }
11214 S::DepositContainers { .. } | S::DepositFilter { .. } => {
11215 if let Some(cid) = wre::pick_storage_container_at(
11216 &self.state.placed_containers,
11217 self.state.character_id,
11218 x,
11219 y,
11220 ) {
11221 self.re_open_deposit_filter(cid);
11222 }
11223 }
11224 S::SellNpcs { .. } => {
11225 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11226 self.re_open_sell_item(Some(npc_id));
11227 }
11228 }
11229 S::SellItem { .. } => {
11230 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11231 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11232 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
11233 *slot = Some(npc_id.clone());
11234 }
11235 }
11236 self.state
11237 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11238 }
11239 }
11240 _ => self.worker_route_editor_quick_add_click(x, y),
11242 }
11243 }
11244
11245 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
11249 use crate::worker_route_editor as wre;
11250 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
11251 let dx = ax - bx;
11252 let dy = ay - by;
11253 (dx * dx + dy * dy).sqrt()
11254 };
11255
11256 let selected_stop_kind = self
11259 .state
11260 .worker_route_editor
11261 .as_ref()
11262 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
11263 .map(|s| match s {
11264 wre::WorkerRouteStop::TradeWith { .. } => 1,
11265 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
11266 _ => 0,
11267 })
11268 .unwrap_or(0);
11269 if selected_stop_kind == 1 {
11270 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11271 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11272 ed.set_selected_trade_npc(npc_id.clone());
11273 }
11274 self.state
11275 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11276 return;
11277 }
11278 }
11279 if selected_stop_kind == 2 {
11280 if let Some(cid) = wre::pick_storage_container_at(
11281 &self.state.placed_containers,
11282 self.state.character_id,
11283 x,
11284 y,
11285 ) {
11286 let name = self
11287 .state
11288 .placed_containers
11289 .iter()
11290 .find(|c| c.id == cid)
11291 .map(|c| c.display_name.clone())
11292 .unwrap_or_else(|| "container".into());
11293 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11294 ed.set_selected_withdraw_container(cid.clone());
11295 }
11296 self.state
11297 .push_log(format!("Route: withdraw source → {name}"));
11298 return;
11299 }
11300 }
11301
11302 enum Target {
11305 Bed(String),
11306 Container(String),
11307 Npc(String, String),
11308 Node(String, String),
11309 }
11310 let mut best: Option<(f32, u8, Target)> = None;
11311 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
11312 let better = match best {
11313 None => true,
11314 Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
11315 };
11316 if better {
11317 *best = Some((d, rank, t));
11318 }
11319 };
11320 if let Some(bed_id) = wre::pick_lodging_container_at(
11321 &self.state.placed_containers,
11322 self.state.character_id,
11323 x,
11324 y,
11325 ) {
11326 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
11327 let already_bed = self
11330 .state
11331 .worker_route_editor
11332 .as_ref()
11333 .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
11334 if already_bed {
11335 consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
11336 } else {
11337 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
11338 }
11339 }
11340 }
11341 if let Some(cid) = wre::pick_storage_container_at(
11342 &self.state.placed_containers,
11343 self.state.character_id,
11344 x,
11345 y,
11346 ) {
11347 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
11348 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
11349 }
11350 }
11351 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11352 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
11353 consider(
11354 dist(x, y, n.x, n.y),
11355 2,
11356 Target::Npc(npc_id, label),
11357 &mut best,
11358 );
11359 }
11360 }
11361 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11362 let d = dist(x, y, node.x, node.y);
11363 consider(
11364 d,
11365 3,
11366 Target::Node(node.id.clone(), node.label.clone()),
11367 &mut best,
11368 );
11369 }
11370
11371 match best.map(|(_, _, t)| t) {
11372 Some(Target::Bed(bed_id)) => {
11373 let name = self
11374 .state
11375 .placed_containers
11376 .iter()
11377 .find(|c| c.id == bed_id)
11378 .map(|c| c.display_name.clone())
11379 .unwrap_or_else(|| "camp bed".into());
11380 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11381 ed.lodging_container_id = Some(bed_id.clone());
11382 }
11383 self.state
11384 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
11385 }
11386 Some(Target::Container(cid)) => {
11387 let name = self
11388 .state
11389 .placed_containers
11390 .iter()
11391 .find(|c| c.id == cid)
11392 .map(|c| c.display_name.clone())
11393 .unwrap_or_else(|| "container".into());
11394 let added = self
11395 .state
11396 .worker_route_editor
11397 .as_mut()
11398 .is_some_and(|ed| ed.append_deposit_at(&cid));
11399 if added {
11400 self.state
11401 .push_log(format!("Route: + deposit at {name} ({cid})"));
11402 } else {
11403 self.state.push_log(format!(
11404 "Route: {name} already in route — selected it (d to remove)"
11405 ));
11406 }
11407 }
11408 Some(Target::Npc(npc_id, label)) => {
11409 let template = self.re_template_candidates().into_iter().next();
11412 let Some(template) = template else {
11413 self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
11414 return;
11415 };
11416 let added = self
11417 .state
11418 .worker_route_editor
11419 .as_mut()
11420 .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
11421 if added {
11422 self.state
11423 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
11424 } else {
11425 self.state.push_log(format!(
11426 "Route: {label} already sells {template} — selected it (d to remove)"
11427 ));
11428 }
11429 }
11430 Some(Target::Node(id, label)) => {
11431 let added = self
11432 .state
11433 .worker_route_editor
11434 .as_mut()
11435 .is_some_and(|ed| ed.append_harvest_node(&id));
11436 if added {
11437 self.state
11438 .push_log(format!("Route: + harvest node {label} ({id})"));
11439 } else {
11440 self.state.push_log(format!(
11441 "Route: {label} already in route — selected it (d to remove)"
11442 ));
11443 }
11444 }
11445 None => {}
11446 }
11447 }
11448
11449 pub fn worker_route_editor_select(&mut self, delta: i32) {
11450 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11451 return;
11452 };
11453 if ed.stops.is_empty() {
11454 return;
11455 }
11456 let n = ed.stops.len() as i32;
11457 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
11458 ed.selected_stop_index = next;
11459 }
11460
11461 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
11462 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11463 return;
11464 };
11465 if delta < 0 {
11466 ed.move_selected_up();
11467 } else if delta > 0 {
11468 ed.move_selected_down();
11469 }
11470 }
11471
11472 pub fn worker_route_editor_delete_selected(&mut self) {
11473 let removed = self
11474 .state
11475 .worker_route_editor
11476 .as_mut()
11477 .is_some_and(|ed| {
11478 let before = ed.stop_count();
11479 ed.remove_selected_stop();
11480 ed.stop_count() < before
11481 });
11482 if removed {
11483 self.state.push_log("Route: removed selected stop");
11484 }
11485 }
11486
11487 pub fn worker_route_editor_clear_stops(&mut self) {
11490 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11491 return;
11492 };
11493 if ed.stops.is_empty() {
11494 self.state.push_log("Route: already empty — s saves an idle worker".to_string());
11495 return;
11496 }
11497 ed.stops.clear();
11498 ed.selected_stop_index = 0;
11499 self.state
11500 .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
11501 }
11502
11503 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
11504 if self.state.pending_worker_job_ack.is_some() {
11505 anyhow::bail!("route save still pending — wait for server ack");
11506 }
11507 let Some(ed) = self.state.worker_route_editor.clone() else {
11508 anyhow::bail!("route editor not open");
11509 };
11510 let (job_yaml, idle) = if ed.stops.is_empty() {
11513 (ed.build_idle_job_yaml(), true)
11514 } else {
11515 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
11516 };
11517 let worker_id = ed.worker_instance_id.clone();
11518 let route_view = if idle {
11519 None
11520 } else {
11521 Some(ed.to_route_view())
11522 };
11523 let mode = if idle {
11524 flatland_protocol::WorkerModeView::Idle
11525 } else {
11526 flatland_protocol::WorkerModeView::JobLoop
11527 };
11528 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
11529 .state
11530 .hired_workers
11531 .iter()
11532 .find(|w| w.instance_id == worker_id)
11533 .map(|w| {
11534 (
11535 w.route.clone(),
11536 w.mode,
11537 w.step_label.clone(),
11538 w.last_error.clone(),
11539 )
11540 })
11541 .unwrap_or((
11542 None,
11543 flatland_protocol::WorkerModeView::Idle,
11544 String::new(),
11545 None,
11546 ));
11547 self.seq += 1;
11548 let seq = self.seq;
11549 self.session
11550 .submit_intent(Intent::SetWorkerJob {
11551 entity_id: self.state.entity_id,
11552 worker_instance_id: worker_id.clone(),
11553 job_yaml,
11554 seq,
11555 })
11556 .await?;
11557 self.state.intents_sent += 1;
11558 if let Some(w) = self
11559 .state
11560 .hired_workers
11561 .iter_mut()
11562 .find(|w| w.instance_id == worker_id)
11563 {
11564 w.route = route_view;
11565 w.mode = mode;
11566 w.last_error = None;
11567 if idle {
11568 w.step_label.clear();
11569 w.route_stop_index = None;
11570 }
11571 }
11572 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
11573 seq,
11574 worker_instance_id: worker_id,
11575 worker_label: ed.worker_label.clone(),
11576 idle,
11577 stop_count: ed.stops.len(),
11578 prev_route,
11579 prev_mode,
11580 prev_step_label,
11581 prev_last_error,
11582 });
11583 self.state.push_log(format!(
11584 "Route: saving for {}… (waiting for server)",
11585 ed.worker_label
11586 ));
11587 Ok(())
11589 }
11590 pub fn quest_menu_move(&mut self, delta: i32) {
11591 let n = self.state.active_quest_entries().len();
11592 if n == 0 {
11593 return;
11594 }
11595 let idx = self.state.quest_menu_index as i32;
11596 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
11597 }
11598
11599 pub fn quest_menu_page(&mut self, pages: i32) {
11600 let n = self.state.active_quest_entries().len();
11601 self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
11602 }
11603
11604 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
11605 let Some(offer) = self.state.pending_quest_offer.clone() else {
11606 anyhow::bail!("no quest offer");
11607 };
11608 self.seq += 1;
11609 let seq = self.seq;
11610 self.session
11611 .submit_intent(Intent::AcceptQuest {
11612 entity_id: self.state.entity_id,
11613 quest_id: offer.quest_id,
11614 seq,
11615 })
11616 .await?;
11617 self.state.intents_sent += 1;
11618 Ok(())
11619 }
11620
11621 pub fn quest_offer_decline(&mut self) {
11622 self.state.show_quest_offer = false;
11623 self.state.pending_quest_offer = None;
11624 if !self.state.show_npc_chat
11625 && !self.state.show_shop_menu
11626 && self.state.npc_verb_target.is_some()
11627 {
11628 self.state.show_npc_verb_menu = true;
11629 }
11630 }
11631
11632 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
11633 if !self.state.show_quest_menu {
11634 return Ok(());
11635 }
11636 let active: Vec<_> = self
11637 .state
11638 .active_quest_entries()
11639 .into_iter()
11640 .cloned()
11641 .collect();
11642 let Some(entry) = active.get(self.state.quest_menu_index) else {
11643 return Ok(());
11644 };
11645 if self.state.quest_withdraw_confirm {
11646 if !entry.can_withdraw {
11647 anyhow::bail!("quest cannot be withdrawn");
11648 }
11649 self.seq += 1;
11650 let seq = self.seq;
11651 self.session
11652 .submit_intent(Intent::WithdrawQuest {
11653 entity_id: self.state.entity_id,
11654 quest_id: entry.quest_id.clone(),
11655 seq,
11656 })
11657 .await?;
11658 self.state.intents_sent += 1;
11659 self.state.quest_withdraw_confirm = false;
11660 return Ok(());
11661 }
11662 self.seq += 1;
11663 let seq = self.seq;
11664 self.session
11665 .submit_intent(Intent::TrackQuest {
11666 entity_id: self.state.entity_id,
11667 quest_id: entry.quest_id.clone(),
11668 seq,
11669 })
11670 .await?;
11671 self.state.intents_sent += 1;
11672 Ok(())
11673 }
11674
11675 pub fn quest_request_withdraw(&mut self) {
11676 if self.state.show_quest_menu {
11677 self.state.quest_withdraw_confirm = true;
11678 }
11679 }
11680
11681 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
11682 if !self.state.is_alive() {
11683 anyhow::bail!("you are dead");
11684 }
11685 let Some(catalog) = self.state.shop_catalog.clone() else {
11686 anyhow::bail!("no shop open");
11687 };
11688 self.seq += 1;
11689 let seq = self.seq;
11690 match self.state.shop_tab {
11691 ShopTab::Buy => {
11692 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
11693 anyhow::bail!("nothing selected");
11694 };
11695 if offer.already_owned {
11696 anyhow::bail!("already owned");
11697 }
11698 self.session
11699 .submit_intent(Intent::ShopBuy {
11700 entity_id: self.state.entity_id,
11701 npc_id: catalog.npc_id.clone(),
11702 offer_id: offer.offer_id.clone(),
11703 quantity: self.state.shop_quantity,
11704 seq,
11705 })
11706 .await?;
11707 }
11708 ShopTab::Sell => {
11709 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
11710 anyhow::bail!("nothing to sell");
11711 };
11712 if line.quantity == 0 {
11713 anyhow::bail!("you have no {}", line.label);
11714 }
11715 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
11716 self.session
11717 .submit_intent(Intent::ShopSell {
11718 entity_id: self.state.entity_id,
11719 npc_id: catalog.npc_id.clone(),
11720 template_id: line.template_id.clone(),
11721 quantity,
11722 seq,
11723 })
11724 .await?;
11725 }
11726 }
11727 self.state.intents_sent += 1;
11728 Ok(())
11729 }
11730
11731 pub fn craft_menu_move(&mut self, delta: i32) {
11732 let n = self.state.blueprints.len();
11733 if n == 0 {
11734 return;
11735 }
11736 let idx = self.state.craft_menu_index as i32;
11737 let next = (idx + delta).rem_euclid(n as i32);
11738 self.state.craft_menu_index = next as usize;
11739 self.state.clamp_craft_batch_quantity();
11740 }
11741
11742 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
11743 self.state.craft_batch_adjust_quantity(delta);
11744 }
11745
11746 pub fn craft_batch_set_max(&mut self) {
11747 self.state.craft_batch_set_max();
11748 }
11749
11750 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
11751 let Some(blueprint) = self
11752 .state
11753 .blueprints
11754 .get(self.state.craft_menu_index)
11755 .cloned()
11756 else {
11757 anyhow::bail!("no blueprints known");
11758 };
11759 if !self.state.can_craft_blueprint(&blueprint) {
11760 let hint = self
11761 .state
11762 .craft_missing_hint(&blueprint)
11763 .unwrap_or_else(|| "missing materials".into());
11764 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
11765 }
11766 let count = self.state.craft_batch_quantity;
11767 let max = self.state.max_craft_batches(&blueprint);
11768 if max == 0 {
11769 anyhow::bail!("cannot craft {}", blueprint.label);
11770 }
11771 let batches = count.min(max);
11772 self.craft(&blueprint.id, Some(batches)).await?;
11773 self.state.show_craft_menu = false;
11774 Ok(())
11775 }
11776
11777 pub async fn move_by(
11778 &mut self,
11779 forward: f32,
11780 strafe: f32,
11781 vertical: f32,
11782 sprint: bool,
11783 ) -> anyhow::Result<()> {
11784 if !self.state.is_alive() {
11785 anyhow::bail!("you are dead");
11786 }
11787 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
11788 self.last_move_forward = forward;
11789 self.last_move_strafe = strafe;
11790 }
11791 self.seq += 1;
11792 self.session
11793 .submit_intent(Intent::Move {
11794 entity_id: self.state.entity_id,
11795 forward,
11796 strafe,
11797 vertical,
11798 sprint,
11799 seq: self.seq,
11800 })
11801 .await?;
11802 self.state.intents_sent += 1;
11803 Ok(())
11804 }
11805
11806 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
11807 if !self.state.connected {
11808 crate::harvest_trace!("harvest_nearest rejected: not connected");
11809 anyhow::bail!("not connected");
11810 }
11811 if !self.state.is_alive() {
11812 crate::harvest_trace!("harvest_nearest rejected: player dead");
11813 anyhow::bail!("you are dead");
11814 }
11815 if self.state.harvest_in_progress {
11816 if self.state.harvest_state_stale() {
11817 self.state.clear_harvest_state();
11818 } else {
11819 anyhow::bail!("already harvesting");
11820 }
11821 }
11822 let (px, py) = self
11823 .state
11824 .player
11825 .as_ref()
11826 .map(|p| (p.transform.position.x, p.transform.position.y))
11827 .unwrap_or((0.0, 0.0));
11828
11829 let available = self
11830 .state
11831 .resource_nodes
11832 .iter()
11833 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
11834 .count();
11835 let node_id = self
11836 .state
11837 .resource_nodes
11838 .iter()
11839 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
11840 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
11841 .min_by(|a, b| {
11842 let da = distance(px, py, a.x, a.y);
11843 let db = distance(px, py, b.x, b.y);
11844 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
11845 })
11846 .map(|n| n.id.clone());
11847
11848 let Some(node_id) = node_id else {
11849 let has_loot = self
11850 .state
11851 .ground_drops
11852 .iter()
11853 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
11854 if has_loot {
11855 return self.pickup_nearest().await;
11856 }
11857 anyhow::bail!(
11858 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
11859 );
11860 };
11861
11862 self.seq += 1;
11863 let seq = self.seq;
11864 crate::harvest_trace!(
11865 entity_id = self.state.entity_id,
11866 node_id = %node_id,
11867 seq,
11868 px,
11869 py,
11870 available_nodes = available,
11871 "submitting harvest intent"
11872 );
11873 self.session
11874 .submit_intent(Intent::Harvest {
11875 entity_id: self.state.entity_id,
11876 node_id,
11877 seq,
11878 })
11879 .await?;
11880 self.state.intents_sent += 1;
11881 self.state.harvest_in_progress = true;
11882 self.state.harvest_started_at = Some(Instant::now());
11883 self.state.push_log("Harvesting…");
11884 crate::harvest_trace!(
11885 entity_id = self.state.entity_id,
11886 seq,
11887 "harvest intent queued to session"
11888 );
11889 Ok(())
11890 }
11891
11892 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
11893 if !self.state.is_alive() {
11894 anyhow::bail!("you are dead");
11895 }
11896 let blueprint_id = self
11897 .state
11898 .blueprints
11899 .iter()
11900 .find(|bp| self.state.can_craft_blueprint(bp))
11901 .map(|bp| bp.id.clone())
11902 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
11903 self.craft(&blueprint_id, None).await
11904 }
11905
11906 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
11907 if !self.state.is_alive() {
11908 anyhow::bail!("you are dead");
11909 }
11910 self.seq += 1;
11911 self.session
11912 .submit_intent(Intent::Craft {
11913 entity_id: self.state.entity_id,
11914 blueprint_id: blueprint_id.to_string(),
11915 count,
11916 seq: self.seq,
11917 })
11918 .await?;
11919 self.state.intents_sent += 1;
11920 let (label, batches) = self
11921 .state
11922 .blueprints
11923 .iter()
11924 .find(|b| b.id == blueprint_id)
11925 .map(|b| {
11926 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
11927 (b.label.as_str(), n)
11928 })
11929 .unwrap_or((blueprint_id, count.unwrap_or(1)));
11930 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
11931 Ok(())
11932 }
11933
11934 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
11935 if !self.state.is_alive() {
11936 anyhow::bail!("you are dead");
11937 }
11938 let target_id = match self.state.nearest_interact_target() {
11939 Some(id) => id,
11940 None => {
11941 anyhow::bail!("nothing to interact with nearby");
11942 }
11943 };
11944 if self.state.npcs.iter().any(|n| n.id == target_id) {
11945 self.state.show_npc_verb_menu = true;
11946 self.state.npc_verb_target = Some(target_id);
11947 self.state.npc_verb_index = 0;
11948 return Ok(());
11949 }
11950 if self
11951 .state
11952 .hired_workers
11953 .iter()
11954 .any(|w| w.instance_id == target_id)
11955 {
11956 return self.open_workers_menu_for(&target_id).await;
11957 }
11958 if let Ok(peer_id) = target_id.parse::<EntityId>() {
11959 if self
11960 .state
11961 .hired_workers
11962 .iter()
11963 .any(|w| w.entity_id == peer_id)
11964 {
11965 if let Some(w) = self
11966 .state
11967 .hired_workers
11968 .iter()
11969 .find(|w| w.entity_id == peer_id)
11970 {
11971 let id = w.instance_id.clone();
11972 return self.open_workers_menu_for(&id).await;
11973 }
11974 }
11975 if let Some(entity) = self
11976 .state
11977 .entities
11978 .iter()
11979 .find(|e| e.id == peer_id && e.id != self.state.entity_id)
11980 {
11981 self.state
11982 .player_verbs
11983 .open_for(peer_id, &entity.label);
11984 return Ok(());
11985 }
11986 }
11987 self.seq += 1;
11988 self.session
11989 .submit_intent(Intent::Interact {
11990 entity_id: self.state.entity_id,
11991 target_id: target_id.clone(),
11992 seq: self.seq,
11993 })
11994 .await?;
11995 self.state.intents_sent += 1;
11996 Ok(())
11997 }
11998
11999 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
12001 if !self.state.is_alive() {
12002 anyhow::bail!("you are dead");
12003 }
12004 let (px, py) = self.state.player_position();
12005 let has_loot = self
12006 .state
12007 .ground_drops
12008 .iter()
12009 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
12010 if has_loot {
12011 return self.pickup_nearest().await;
12012 }
12013 if self
12014 .state
12015 .placed_containers
12016 .iter()
12017 .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
12018 {
12019 return self.pickup_nearest_container().await;
12020 }
12021
12022 if let Some(plot) = self.state.my_plot_under_player().cloned() {
12023 const SELL_WINDOW: Duration = Duration::from_millis(1200);
12025 let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
12026 && self
12027 .state
12028 .sell_plot_armed_at
12029 .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
12030 if sell_armed {
12031 return self.confirm_sell_plot_to_crown(plot.plot_id).await;
12032 }
12033 self.state.sell_plot_confirm = None;
12034 self.state.sell_plot_armed_at = None;
12035
12036 let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
12039 self.state.npcs.iter().any(|n| n.id == id)
12040 || self.state.hired_workers.iter().any(|w| w.instance_id == id)
12041 || self.state.doors.iter().any(|d| d.id == id)
12042 || self.state.interactables.iter().any(|i| {
12043 i.id == id
12044 && matches!(
12045 i.kind.as_str(),
12046 "quest_board" | "well" | "exit" | "enter"
12047 )
12048 })
12049 || id.parse::<EntityId>().is_ok_and(|eid| {
12050 self.state
12051 .entities
12052 .iter()
12053 .any(|e| e.id == eid && e.id != self.state.entity_id)
12054 })
12055 });
12056 if !blocking_interact {
12057 match self.harvest_nearest().await {
12059 Ok(()) => return Ok(()),
12060 Err(err) => {
12061 let msg = err.to_string();
12062 if !(msg.contains("no harvestable")
12063 || msg.contains("press p")
12064 || msg.contains("press f")
12065 || msg.contains("nothing"))
12066 {
12067 return Err(err);
12068 }
12069 }
12070 }
12071 return Ok(());
12072 }
12073 }
12074 if self.state.nearest_interact_target().is_some() {
12075 return self.interact_nearest().await;
12076 }
12077 if let Some((label, dist)) = self.state.nearest_quest_board() {
12080 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
12081 anyhow::bail!(
12082 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
12083 );
12084 }
12085 }
12086
12087 match self.harvest_nearest().await {
12088 Ok(()) => Ok(()),
12089 Err(err) => {
12090 let msg = err.to_string();
12091 if msg.contains("no harvestable")
12092 || msg.contains("press p")
12093 || msg.contains("press f")
12094 {
12095 anyhow::bail!(
12096 "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
12097 );
12098 }
12099 Err(err)
12100 }
12101 }
12102 }
12103
12104 pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
12106 if !self.state.is_alive() {
12107 anyhow::bail!("you are dead");
12108 }
12109 if self.state.claim_mode.is_some() {
12110 anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
12111 }
12112 let zone = self
12113 .state
12114 .free_property_zone_under_player()
12115 .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
12116 let zone_id = zone.id.clone();
12117 let label = zone
12118 .label
12119 .as_deref()
12120 .filter(|s| !s.trim().is_empty())
12121 .unwrap_or(zone.id.as_str())
12122 .to_string();
12123 self.enter_claim_mode(&zone_id);
12124 self.state
12125 .push_log(format!(
12126 "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
12127 ));
12128 Ok(())
12129 }
12130
12131 pub fn enter_claim_mode(&mut self, zone_id: &str) {
12133 let Some(zone) = self
12134 .state
12135 .property_zones
12136 .iter()
12137 .find(|z| z.id == zone_id)
12138 .cloned()
12139 else {
12140 self.state.push_log("unknown property zone");
12141 return;
12142 };
12143 self.state.sell_plot_confirm = None;
12144 self.state.sell_plot_armed_at = None;
12145 let min_area = self
12146 .state
12147 .property_plot_settings
12148 .as_ref()
12149 .map(|s| s.min_plot_area_m2)
12150 .unwrap_or(4.0)
12151 .max(1.0);
12152 let min_side = min_area.sqrt().ceil().max(1.0) as u32;
12153 let side = 4u32.max(min_side);
12154 let (px, py) = self.state.player_position();
12155 let anchor_x = px.floor();
12156 let anchor_y = py.floor();
12157 self.state.claim_mode = Some(ClaimModeState {
12158 zone_id: zone.id.clone(),
12159 width_m: side,
12160 height_m: side,
12161 anchor_x,
12162 anchor_y,
12163 });
12164 let label = zone
12165 .label
12166 .as_deref()
12167 .filter(|s| !s.trim().is_empty())
12168 .unwrap_or(zone.id.as_str());
12169 self.state.push_log(format!(
12170 "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
12171 ));
12172 }
12173
12174 pub fn cancel_claim_mode(&mut self) {
12175 if self.state.claim_mode.take().is_some() {
12176 self.state.push_log("Claim cancelled");
12177 }
12178 }
12179
12180 pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
12182 if !self.state.is_alive() {
12183 anyhow::bail!("you are dead");
12184 }
12185 if self.state.relocate_mode.is_some() {
12186 anyhow::bail!("already relocating — Enter confirm, Esc cancel");
12187 }
12188 if self.state.claim_mode.is_some() {
12189 anyhow::bail!("finish or cancel claim mode first");
12190 }
12191 let chest = self
12192 .state
12193 .placed_containers
12194 .iter()
12195 .find(|c| c.id == container_id)
12196 .cloned()
12197 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
12198 let (px, py) = self.state.player_position();
12199 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
12200 anyhow::bail!("too far from {}", chest.display_name);
12201 }
12202 if chest.locked && !chest.accessible {
12203 anyhow::bail!(
12204 "need the matching key for {} before moving it",
12205 chest.display_name
12206 );
12207 }
12208 let label = if chest.display_name.trim().is_empty() {
12209 chest.template_id.clone()
12210 } else {
12211 chest.display_name.clone()
12212 };
12213 self.state.relocate_mode = Some(RelocateModeState {
12214 container_id: chest.id.clone(),
12215 label: label.clone(),
12216 cursor_x: chest.x.floor() + 0.5,
12217 cursor_y: chest.y.floor() + 0.5,
12218 });
12219 self.state.push_log(format!(
12220 "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
12221 ));
12222 Ok(())
12223 }
12224
12225 pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
12227 let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
12228 anyhow::bail!("no chest nearby to relocate");
12229 };
12230 if chest.locked && !chest.accessible {
12231 anyhow::bail!(
12232 "need the matching key for {} before moving it",
12233 chest.display_name
12234 );
12235 }
12236 self.begin_relocate_container(&chest.id)
12239 }
12240
12241 pub fn cancel_relocate_mode(&mut self) {
12242 if self.state.relocate_mode.take().is_some() {
12243 self.state.push_log("Relocate cancelled");
12244 }
12245 }
12246
12247 pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
12248 let Some(mode) = self.state.relocate_mode.as_mut() else {
12249 return;
12250 };
12251 let max_x = self.state.world_width_m.max(1.0);
12252 let max_y = self.state.world_height_m.max(1.0);
12253 let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
12254 let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
12255 mode.cursor_x = nx.floor() + 0.5;
12256 mode.cursor_y = ny.floor() + 0.5;
12257 }
12258
12259 pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
12260 let Some(mode) = self.state.relocate_mode.as_mut() else {
12261 return;
12262 };
12263 let max_x = self.state.world_width_m.max(1.0);
12264 let max_y = self.state.world_height_m.max(1.0);
12265 mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
12266 mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
12267 }
12268
12269 pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
12270 if !self.state.is_alive() {
12271 anyhow::bail!("you are dead");
12272 }
12273 let Some(mode) = self.state.relocate_mode.clone() else {
12274 anyhow::bail!("not relocating");
12275 };
12276 let (px, py) = self.state.player_position();
12277 let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
12278 if dist > 8.0 {
12279 anyhow::bail!("destination too far (max 8 m)");
12280 }
12281 self.seq += 1;
12282 self.session
12283 .submit_intent(Intent::MovePlacedContainer {
12284 entity_id: self.state.entity_id,
12285 container_id: mode.container_id.clone(),
12286 x: mode.cursor_x,
12287 y: mode.cursor_y,
12288 seq: self.seq,
12289 })
12290 .await?;
12291 self.state.intents_sent += 1;
12292 self.state.relocate_mode = None;
12293 self.state
12294 .push_log(format!("Moving {}…", mode.label));
12295 Ok(())
12296 }
12297
12298 pub fn claim_set_preset(&mut self, w: u32, h: u32) {
12299 let Some(mode) = self.state.claim_mode.as_mut() else {
12300 return;
12301 };
12302 mode.width_m = w.max(1);
12303 mode.height_m = h.max(1);
12304 }
12305
12306 pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
12307 let Some(mode) = self.state.claim_mode.as_mut() else {
12308 return;
12309 };
12310 let w = (mode.width_m as i32 + dw).max(1) as u32;
12311 let h = (mode.height_m as i32 + dh).max(1) as u32;
12312 mode.width_m = w;
12313 mode.height_m = h;
12314 }
12315
12316 pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
12318 let Some(mode) = self.state.claim_mode.as_mut() else {
12319 return;
12320 };
12321 let max_x = self.state.world_width_m.max(1.0);
12322 let max_y = self.state.world_height_m.max(1.0);
12323 let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
12324 let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
12325 mode.anchor_x = nx.floor();
12326 mode.anchor_y = ny.floor();
12327 }
12328
12329 pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
12330 if !self.state.is_alive() {
12331 anyhow::bail!("you are dead");
12332 }
12333 let Some(mode) = self.state.claim_mode.clone() else {
12334 anyhow::bail!("not in claim mode");
12335 };
12336 let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
12337 self.state.claim_quote()
12338 else {
12339 anyhow::bail!("cannot quote claim");
12340 };
12341 if !valid {
12342 anyhow::bail!(reason);
12343 }
12344 if !can_afford {
12345 anyhow::bail!(
12346 "not enough copper (need {})",
12347 crate::currency::format_copper(purchase)
12348 );
12349 }
12350 let (x0, y0, x1, y1) = self
12351 .state
12352 .claim_footprint_rect()
12353 .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
12354 let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
12355 self.seq += 1;
12356 self.session
12357 .submit_intent(Intent::BuyPlot {
12358 entity_id: self.state.entity_id,
12359 zone_id: mode.zone_id,
12360 x0,
12361 y0,
12362 x1,
12363 y1,
12364 seq: self.seq,
12365 })
12366 .await?;
12367 self.state.intents_sent += 1;
12368 self.state.claim_mode = None;
12369 self.state
12370 .push_log(format!("Buying plot for {}", crate::currency::format_copper(purchase)));
12371 Ok(())
12372 }
12373
12374 pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
12375 if !self.state.is_alive() {
12376 anyhow::bail!("you are dead");
12377 }
12378 let zone_id = self
12379 .state
12380 .claim_mode
12381 .as_ref()
12382 .map(|m| m.zone_id.clone())
12383 .or_else(|| {
12384 self.state
12385 .free_property_zone_under_player()
12386 .map(|z| z.id.clone())
12387 })
12388 .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
12389 self.seq += 1;
12390 self.session
12391 .submit_intent(Intent::BuyPlotAllFree {
12392 entity_id: self.state.entity_id,
12393 zone_id,
12394 seq: self.seq,
12395 })
12396 .await?;
12397 self.state.intents_sent += 1;
12398 self.state.claim_mode = None;
12399 self.state.push_log("Claiming largest free plot…");
12400 Ok(())
12401 }
12402
12403 pub async fn confirm_sell_plot_to_crown(
12404 &mut self,
12405 plot_id: uuid::Uuid,
12406 ) -> anyhow::Result<()> {
12407 if !self.state.is_alive() {
12408 anyhow::bail!("you are dead");
12409 }
12410 self.seq += 1;
12411 self.session
12412 .submit_intent(Intent::SellPlotToCrown {
12413 entity_id: self.state.entity_id,
12414 plot_id,
12415 seq: self.seq,
12416 })
12417 .await?;
12418 self.state.intents_sent += 1;
12419 self.state.sell_plot_confirm = None;
12420 self.state.sell_plot_armed_at = None;
12421 self.state.push_log("Selling plot to the crown…");
12422 Ok(())
12423 }
12424
12425 pub async fn set_plot_farm_public(
12426 &mut self,
12427 plot_id: uuid::Uuid,
12428 public: bool,
12429 public_tax_discount_bps: u32,
12430 ) -> anyhow::Result<()> {
12431 self.seq += 1;
12432 self.session
12433 .submit_intent(Intent::SetPlotFarmPublic {
12434 entity_id: self.state.entity_id,
12435 plot_id,
12436 public,
12437 public_tax_discount_bps,
12438 seq: self.seq,
12439 })
12440 .await?;
12441 self.state.intents_sent += 1;
12442 Ok(())
12443 }
12444
12445 pub async fn plot_farm_allow_upsert(
12446 &mut self,
12447 plot_id: uuid::Uuid,
12448 character_id: Option<uuid::Uuid>,
12449 character_name: String,
12450 tax_discount_bps: u32,
12451 ) -> anyhow::Result<()> {
12452 self.seq += 1;
12453 self.session
12454 .submit_intent(Intent::PlotFarmAllowUpsert {
12455 entity_id: self.state.entity_id,
12456 plot_id,
12457 character_id,
12458 character_name,
12459 tax_discount_bps,
12460 seq: self.seq,
12461 })
12462 .await?;
12463 self.state.intents_sent += 1;
12464 Ok(())
12465 }
12466
12467 pub async fn plot_farm_allow_remove(
12468 &mut self,
12469 plot_id: uuid::Uuid,
12470 character_id: uuid::Uuid,
12471 ) -> anyhow::Result<()> {
12472 self.seq += 1;
12473 self.session
12474 .submit_intent(Intent::PlotFarmAllowRemove {
12475 entity_id: self.state.entity_id,
12476 plot_id,
12477 character_id,
12478 seq: self.seq,
12479 })
12480 .await?;
12481 self.state.intents_sent += 1;
12482 Ok(())
12483 }
12484
12485 pub fn open_farm_access_panel(&mut self) {
12486 let Some(plot) = self.state.my_plot_under_player() else {
12487 self.state
12488 .push_log("Stand on your deed plot to manage farm access");
12489 return;
12490 };
12491 self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
12492 self.state.farm_access_index = 0;
12493 self.state.show_farm_access = true;
12494 }
12495
12496 pub fn close_farm_access_panel(&mut self) {
12497 self.state.show_farm_access = false;
12498 self.state.farm_access_name_draft.clear();
12499 self.state.farm_access_index = 0;
12500 }
12501
12502 pub fn farm_access_move(&mut self, delta: i32) {
12503 let n = self.farm_access_row_count().max(1);
12504 let idx = self.state.farm_access_index as i32 + delta;
12505 self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
12506 }
12507
12508 pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
12509 let Some(plot) = self.state.my_plot_under_player() else {
12510 return vec![FarmAccessRow::PublicToggle];
12511 };
12512 let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
12513 for g in &plot.farm_allow {
12514 rows.push(FarmAccessRow::AllowRemove {
12515 character_id: g.character_id,
12516 label: if g.character_label.trim().is_empty() {
12517 g.character_id.to_string()[..8].to_string()
12518 } else {
12519 g.character_label.clone()
12520 },
12521 tax_discount_bps: g.tax_discount_bps,
12522 });
12523 }
12524 for e in &self.state.entities {
12525 if e.id == self.state.entity_id || e.label.trim().is_empty() {
12526 continue;
12527 }
12528 if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
12529 continue;
12530 }
12531 if self
12532 .state
12533 .npcs
12534 .iter()
12535 .any(|n| n.id == e.label || n.label == e.label)
12536 {
12537 continue;
12538 }
12539 if plot
12540 .farm_allow
12541 .iter()
12542 .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
12543 {
12544 continue;
12545 }
12546 rows.push(FarmAccessRow::NearbyAdd {
12547 name: e.label.clone(),
12548 });
12549 }
12550 rows
12551 }
12552
12553 pub fn farm_access_row_count(&self) -> usize {
12554 self.farm_access_rows().len().max(1)
12555 }
12556
12557 pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
12558 let Some(plot) = self.state.my_plot_under_player().cloned() else {
12559 self.close_farm_access_panel();
12560 return Ok(());
12561 };
12562 let rows = self.farm_access_rows();
12563 let Some(row) = rows.get(self.state.farm_access_index) else {
12564 return Ok(());
12565 };
12566 match row {
12567 FarmAccessRow::PublicToggle => {
12568 self.set_plot_farm_public(
12569 plot.plot_id,
12570 !plot.farm_public,
12571 plot.public_tax_discount_bps,
12572 )
12573 .await
12574 }
12575 FarmAccessRow::PublicDiscount => Ok(()),
12576 FarmAccessRow::AllowRemove { character_id, .. } => {
12577 self.plot_farm_allow_remove(plot.plot_id, *character_id)
12578 .await
12579 }
12580 FarmAccessRow::NearbyAdd { name } => {
12581 let disc = self
12582 .state
12583 .farm_access_discount_bps
12584 .max(plot.public_tax_discount_bps);
12585 self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
12586 .await
12587 }
12588 }
12589 }
12590
12591 pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
12592 let Some(plot) = self.state.my_plot_under_player().cloned() else {
12593 return Ok(());
12594 };
12595 let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
12596 self.state.farm_access_discount_bps = next;
12597 self.state.farm_access_index = 1;
12598 self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
12599 .await
12600 }
12601
12602 pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
12604 if self.state.farmable_plot_under_player().is_none() {
12605 anyhow::bail!("stand on a farmable plot to cultivate");
12606 }
12607 let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
12608 let (px, py) = self.state.player_position();
12609 if self
12610 .state
12611 .terrain_at(px, py)
12612 .is_some_and(|k| k == TerrainKindView::Tilled)
12613 {
12614 anyhow::bail!("already tilled — stand on bare soil and press c");
12615 }
12616 anyhow::bail!("cannot till this cell — move onto soil on your plot");
12617 };
12618 self.cultivate_at(tx, ty).await
12619 }
12620
12621 pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
12623 if self.state.farmable_plot_under_player().is_none() {
12624 anyhow::bail!("stand on a farmable plot to plant");
12625 }
12626 if !self.state.underfoot_free_tilled_plant_slot() {
12627 anyhow::bail!("stand on empty tilled soil and press p");
12628 }
12629 let seeds = self.state.farm_seed_entries();
12630 if seeds.is_empty() {
12631 anyhow::bail!("no seeds in inventory — buy seeds from Eli");
12632 }
12633 if seeds.len() == 1 {
12634 return self.plant_seeds(seeds[0].0.clone(), 1).await;
12635 }
12636 self.open_plant_menu();
12637 Ok(())
12638 }
12639
12640 pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
12641 if !self.state.is_alive() {
12642 anyhow::bail!("you are dead");
12643 }
12644 self.seq += 1;
12645 self.session
12646 .submit_intent(Intent::Cultivate {
12647 entity_id: self.state.entity_id,
12648 x,
12649 y,
12650 seq: self.seq,
12651 })
12652 .await?;
12653 self.state.intents_sent += 1;
12654 Ok(())
12655 }
12656
12657 pub async fn plant_seeds(
12658 &mut self,
12659 seed_template_id: String,
12660 quantity: u32,
12661 ) -> anyhow::Result<()> {
12662 if !self.state.is_alive() {
12663 anyhow::bail!("you are dead");
12664 }
12665 self.seq += 1;
12666 self.session
12667 .submit_intent(Intent::PlantSeeds {
12668 entity_id: self.state.entity_id,
12669 seed_template_id: seed_template_id.clone(),
12670 quantity,
12671 seq: self.seq,
12672 })
12673 .await?;
12674 self.state.intents_sent += 1;
12675 self.state
12676 .push_log(format!("Planting {quantity}× {seed_template_id}…"));
12677 Ok(())
12678 }
12679
12680 pub fn open_plant_menu(&mut self) {
12681 if self.state.farm_seed_entries().is_empty() {
12682 self.state.push_log("No seeds in inventory to plant");
12683 return;
12684 }
12685 self.state.show_plant_menu = true;
12686 self.state.plant_menu_index = 0;
12687 self.state.plant_quantity = 1;
12688 self.state.clamp_plant_menu();
12689 }
12690
12691 pub fn close_plant_menu(&mut self) {
12692 self.state.show_plant_menu = false;
12693 }
12694
12695 pub fn plant_menu_move(&mut self, delta: i32) {
12696 let n = self.state.farm_seed_entries().len();
12697 if n == 0 {
12698 return;
12699 }
12700 let idx = self.state.plant_menu_index as i32 + delta;
12701 self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
12702 self.state.clamp_plant_menu();
12703 }
12704
12705 pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
12706 let next = self.state.plant_quantity as i32 + delta;
12707 self.state.plant_quantity = next.max(1) as u32;
12708 self.state.clamp_plant_menu();
12709 }
12710
12711 pub fn plant_menu_set_quantity_max(&mut self) {
12712 if let Some((_, max, _)) = self.state.plant_menu_selection() {
12713 self.state.plant_quantity = max;
12714 }
12715 self.state.clamp_plant_menu();
12716 }
12717
12718 pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
12719 let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
12720 self.close_plant_menu();
12721 anyhow::bail!("no seeds to plant");
12722 };
12723 self.close_plant_menu();
12724 self.plant_seeds(seed, qty).await?;
12725 self.state.push_log(format!("Planted {qty}× {label}"));
12726 Ok(())
12727 }
12728
12729 pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
12732 if !self.state.is_alive() {
12733 anyhow::bail!("you are dead");
12734 }
12735 let binding = self
12736 .state
12737 .hotbar_ability(slot)
12738 .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
12739 .to_string();
12740 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
12741 let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
12742 if qty == 0 {
12743 anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
12744 }
12745 return self.use_item(template_id).await;
12746 }
12747 let ability_id = binding;
12748 if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
12749 return self
12750 .cast_ability(&ability_id, Some(self.state.entity_id))
12751 .await;
12752 }
12753 let is_heal = ability_id == "heal_touch"
12754 || self
12755 .state
12756 .ability_meta
12757 .get(&ability_id)
12758 .map(|meta| meta.is_heal)
12759 .unwrap_or(false);
12760 let target = if is_heal {
12761 Some(
12762 self.state
12763 .target_for_slot(2)
12764 .unwrap_or(self.state.entity_id),
12765 )
12766 } else {
12767 self.state
12768 .target_for_slot(1)
12769 .or_else(|| self.state.target_for_slot(2))
12770 };
12771 let Some(target_id) = target else {
12772 anyhow::bail!("no target — Tab to select, then press the hotbar key");
12773 };
12774 self.cast_ability(&ability_id, Some(target_id)).await
12775 }
12776
12777 pub async fn set_hotbar_slot(
12780 &mut self,
12781 slot: u8,
12782 ability_id: Option<&str>,
12783 ) -> anyhow::Result<()> {
12784 if !self.state.is_alive() {
12785 anyhow::bail!("you are dead");
12786 }
12787 if !(1..=9).contains(&slot) {
12788 anyhow::bail!("hotbar slot must be 1–9");
12789 }
12790 let ability_id = ability_id
12791 .map(str::trim)
12792 .filter(|id| !id.is_empty())
12793 .map(str::to_string);
12794 self.seq += 1;
12795 self.session
12796 .submit_intent(Intent::SetHotbarSlot {
12797 entity_id: self.state.entity_id,
12798 slot,
12799 ability_id: ability_id.clone(),
12800 seq: self.seq,
12801 })
12802 .await?;
12803 self.state.intents_sent += 1;
12804 let idx = (slot - 1) as usize;
12805 if self.state.hotbar.len() < 9 {
12806 self.state.hotbar.resize(9, None);
12807 }
12808 if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
12809 *slot_mut = ability_id.clone();
12810 }
12811 match ability_id {
12812 Some(id) => {
12813 let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
12814 format!("use {tid}")
12815 } else {
12816 id
12817 };
12818 self.state.push_log(format!("Hotbar {slot} → {label}"))
12819 }
12820 None => self.state.push_log(format!("Hotbar {slot} cleared")),
12821 }
12822 Ok(())
12823 }
12824
12825 pub fn npc_verb_options(&self) -> Vec<&'static str> {
12826 self.state.npc_verb_options()
12827 }
12828
12829 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
12830 let Some(npc_id) = self.state.npc_verb_target.clone() else {
12831 return Ok(());
12832 };
12833 let options = self.npc_verb_options();
12834 let choice = options
12835 .get(self.state.npc_verb_index)
12836 .copied()
12837 .unwrap_or("Talk");
12838 self.seq += 1;
12839 match choice {
12840 "Trade" | "Bank" | "Storage" | "Market" => {
12841 self.session
12842 .submit_intent(Intent::Interact {
12843 entity_id: self.state.entity_id,
12844 target_id: npc_id,
12845 seq: self.seq,
12846 })
12847 .await?;
12848 }
12849 _ => {
12850 self.session
12851 .submit_intent(Intent::NpcTalkOpen {
12852 entity_id: self.state.entity_id,
12853 npc_id,
12854 seq: self.seq,
12855 })
12856 .await?;
12857 }
12858 }
12859 self.state.intents_sent += 1;
12860 Ok(())
12861 }
12862
12863 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
12864 let Some(chat) = self.state.npc_chat.clone() else {
12865 return Ok(());
12866 };
12867 let message = chat.input.trim().to_string();
12868 if message.is_empty() || chat.pending {
12869 return Ok(());
12870 }
12871 if let Some(c) = self.state.npc_chat.as_mut() {
12872 c.lines.push(format!("You: {message}"));
12873 c.input.clear();
12874 c.pending = true;
12875 }
12876 self.seq += 1;
12877 self.session
12878 .submit_intent(Intent::NpcTalkSay {
12879 entity_id: self.state.entity_id,
12880 npc_id: chat.npc_id,
12881 message,
12882 seq: self.seq,
12883 })
12884 .await?;
12885 self.state.intents_sent += 1;
12886 Ok(())
12887 }
12888
12889 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
12890 let return_to_verbs = self.state.npc_verb_target.is_some();
12891 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
12892 self.state.show_npc_chat = false;
12893 if return_to_verbs {
12894 self.state.show_npc_verb_menu = true;
12895 }
12896 return Ok(());
12897 };
12898 self.seq += 1;
12899 self.session
12900 .submit_intent(Intent::NpcTalkClose {
12901 entity_id: self.state.entity_id,
12902 npc_id,
12903 seq: self.seq,
12904 })
12905 .await?;
12906 self.state.intents_sent += 1;
12907 self.state.show_npc_chat = false;
12908 self.state.npc_chat = None;
12909 if return_to_verbs {
12910 self.state.show_npc_verb_menu = true;
12911 }
12912 Ok(())
12913 }
12914
12915 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
12917 if self.state.show_quest_offer
12918 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
12919 {
12920 self.quest_offer_decline();
12921 return Ok(());
12922 }
12923 if self.state.show_npc_chat {
12924 return self.npc_talk_close().await;
12925 }
12926 if self.state.show_shop_menu {
12927 return self.back_from_shop_menu().await;
12928 }
12929 if self.state.bank_panel.is_some() {
12930 if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
12931 self.bank_transfer_back();
12932 return Ok(());
12933 }
12934 return self.close_bank_panel().await;
12935 }
12936 if self.state.storage_panel.is_some() {
12937 if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
12938 self.storage_ui_back();
12939 return Ok(());
12940 }
12941 return self.close_storage_panel().await;
12942 }
12943 if self.state.market_panel.is_some() {
12944 if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
12945 self.market_ui_back();
12946 return Ok(());
12947 }
12948 if self.state.market_buy_confirm.is_some() {
12949 self.state.market_buy_confirm = None;
12950 return Ok(());
12951 }
12952 return self.close_market_panel().await;
12953 }
12954 if self.state.show_npc_verb_menu {
12955 self.state.show_npc_verb_menu = false;
12956 self.state.npc_verb_target = None;
12957 }
12958 Ok(())
12959 }
12960
12961 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
12962 self.seq += 1;
12963 self.session
12964 .submit_intent(Intent::TestDamage {
12965 entity_id: self.state.entity_id,
12966 amount,
12967 seq: self.seq,
12968 })
12969 .await?;
12970 self.state.intents_sent += 1;
12971 Ok(())
12972 }
12973
12974 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
12975 self.cycle_combat_target_slot(1, reverse).await
12976 }
12977
12978 pub async fn cycle_combat_target_slot(
12979 &mut self,
12980 slot_index: u8,
12981 reverse: bool,
12982 ) -> anyhow::Result<()> {
12983 if !self.state.is_alive() {
12984 anyhow::bail!("you are dead");
12985 }
12986 let candidates = self.state.candidates_for_slot(slot_index);
12987 if candidates.is_empty() {
12988 anyhow::bail!("no targets nearby");
12989 }
12990 let current = self.state.target_for_slot(slot_index);
12991 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
12992 let next_idx = match idx {
12993 None => 0,
12994 Some(i) if reverse => {
12995 if i == 0 {
12996 candidates.len() - 1
12997 } else {
12998 i - 1
12999 }
13000 }
13001 Some(i) => (i + 1) % candidates.len(),
13002 };
13003 if idx == Some(next_idx) && candidates.len() == 1 {
13004 self.clear_combat_target_slot(slot_index).await?;
13005 return Ok(());
13006 }
13007 let (target_id, label) = candidates[next_idx].clone();
13008 self.set_combat_target_slot(slot_index, target_id, &label)
13009 .await
13010 }
13011
13012 pub async fn set_combat_target_slot(
13013 &mut self,
13014 slot_index: u8,
13015 target_id: EntityId,
13016 label: &str,
13017 ) -> anyhow::Result<()> {
13018 if !self.state.is_alive() {
13019 anyhow::bail!("you are dead");
13020 }
13021 self.seq += 1;
13022 self.session
13023 .submit_intent(Intent::SetTargetSlot {
13024 entity_id: self.state.entity_id,
13025 slot_index,
13026 target_id,
13027 seq: self.seq,
13028 })
13029 .await?;
13030 self.state.intents_sent += 1;
13031 if slot_index == 1 {
13032 self.state.combat_target = Some(target_id);
13033 self.state.combat_target_label = Some(label.to_string());
13034 }
13035 self.state
13036 .push_log(format!("Slot {slot_index} target: {label}"));
13037 Ok(())
13038 }
13039
13040 pub async fn set_combat_target(
13041 &mut self,
13042 target_id: EntityId,
13043 label: &str,
13044 ) -> anyhow::Result<()> {
13045 self.set_combat_target_slot(1, target_id, label).await
13046 }
13047
13048 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
13049 if slot_index == 1 && self.state.combat_target.is_none() {
13050 return Ok(());
13051 }
13052 self.seq += 1;
13053 self.session
13054 .submit_intent(Intent::ClearTargetSlot {
13055 entity_id: self.state.entity_id,
13056 slot_index,
13057 seq: self.seq,
13058 })
13059 .await?;
13060 if slot_index == 1 {
13061 self.state.combat_target = None;
13062 self.state.combat_target_label = None;
13063 }
13064 self.state.intents_sent += 1;
13065 self.state
13066 .push_log(format!("Slot {slot_index} target cleared"));
13067 Ok(())
13068 }
13069
13070 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
13071 self.clear_combat_target_slot(1).await
13072 }
13073
13074 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
13075 if !self.state.is_alive() {
13076 anyhow::bail!("you are dead");
13077 }
13078 self.seq += 1;
13079 self.session
13080 .submit_intent(Intent::AdvanceRotation {
13081 entity_id: self.state.entity_id,
13082 slot_index,
13083 seq: self.seq,
13084 })
13085 .await?;
13086 self.state.intents_sent += 1;
13087 Ok(())
13088 }
13089
13090 pub async fn assign_slot_preset(
13091 &mut self,
13092 slot_index: u8,
13093 preset_id: &str,
13094 ) -> anyhow::Result<()> {
13095 if !self.state.is_alive() {
13096 anyhow::bail!("you are dead");
13097 }
13098 self.seq += 1;
13099 self.session
13100 .submit_intent(Intent::AssignSlotPreset {
13101 entity_id: self.state.entity_id,
13102 slot_index,
13103 preset_id: preset_id.to_string(),
13104 seq: self.seq,
13105 })
13106 .await?;
13107 self.state.intents_sent += 1;
13108 if let Some(slot) = self
13109 .state
13110 .combat_slots
13111 .iter_mut()
13112 .find(|s| s.slot_index == slot_index)
13113 {
13114 slot.preset_id = Some(preset_id.to_string());
13115 if let Some(preset) = self
13116 .state
13117 .rotation_presets
13118 .iter()
13119 .find(|p| p.id == preset_id)
13120 {
13121 slot.preset_label = Some(preset.label.clone());
13122 slot.rotation = preset.abilities.clone();
13123 slot.rotation_index = 0;
13124 }
13125 }
13126 self.state
13127 .push_log(format!("T{slot_index} loadout → {preset_id}"));
13128 Ok(())
13129 }
13130
13131 pub async fn cast_ability(
13132 &mut self,
13133 ability_id: &str,
13134 target_id: Option<EntityId>,
13135 ) -> anyhow::Result<()> {
13136 if !self.state.is_alive() {
13137 anyhow::bail!("you are dead");
13138 }
13139 let allows_ground = self.state.ability_allows_ground(ability_id);
13140 let requires_ground = self.state.ability_requires_ground(ability_id);
13141 if requires_ground && self.state.ground_target.is_none() {
13142 anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
13143 }
13144 let (resolved_target_id, target_point) = if allows_ground {
13145 if let Some((x, y, z)) = self.state.ground_target {
13146 (
13147 target_id.unwrap_or(self.state.entity_id),
13148 Some(flatland_protocol::AimPoint { x, y, z }),
13149 )
13150 } else {
13151 (
13152 target_id
13153 .or_else(|| self.state.target_for_slot(2))
13154 .or_else(|| self.state.target_for_slot(1))
13155 .unwrap_or(self.state.entity_id),
13156 None,
13157 )
13158 }
13159 } else {
13160 (
13161 target_id
13162 .or_else(|| self.state.target_for_slot(2))
13163 .or_else(|| self.state.target_for_slot(1))
13164 .unwrap_or(self.state.entity_id),
13165 None,
13166 )
13167 };
13168 self.seq += 1;
13169 self.session
13170 .submit_intent(Intent::Cast {
13171 entity_id: self.state.entity_id,
13172 ability_id: ability_id.to_string(),
13173 target_id: resolved_target_id,
13174 target_point,
13175 seq: self.seq,
13176 })
13177 .await?;
13178 self.state.intents_sent += 1;
13179 match target_point {
13180 Some(point) => self.state.push_log(format!(
13181 "Cast {ability_id} → ({:.1}, {:.1})",
13182 point.x, point.y
13183 )),
13184 None => self
13185 .state
13186 .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
13187 }
13188 Ok(())
13189 }
13190
13191 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
13192 self.seq += 1;
13193 self.session
13194 .submit_intent(Intent::UpsertRotationPreset {
13195 entity_id: self.state.entity_id,
13196 preset: preset.clone(),
13197 seq: self.seq,
13198 })
13199 .await?;
13200 self.state.intents_sent += 1;
13201 if let Some(existing) = self
13202 .state
13203 .rotation_presets
13204 .iter_mut()
13205 .find(|p| p.id == preset.id)
13206 {
13207 *existing = preset.clone();
13208 } else {
13209 self.state.rotation_presets.push(preset.clone());
13210 }
13211 for slot in &mut self.state.combat_slots {
13212 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
13213 slot.preset_label = Some(preset.label.clone());
13214 slot.rotation = preset.abilities.clone();
13215 }
13216 }
13217 self.state
13218 .push_log(format!("Saved rotation: {}", preset.label));
13219 Ok(())
13220 }
13221
13222 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
13223 self.seq += 1;
13224 self.session
13225 .submit_intent(Intent::DeleteRotationPreset {
13226 entity_id: self.state.entity_id,
13227 preset_id: preset_id.to_string(),
13228 seq: self.seq,
13229 })
13230 .await?;
13231 self.state.intents_sent += 1;
13232 self.state.rotation_presets.retain(|p| p.id != preset_id);
13233 for slot in &mut self.state.combat_slots {
13234 if slot.preset_id.as_deref() == Some(preset_id) {
13235 slot.preset_id = None;
13236 slot.preset_label = None;
13237 slot.rotation.clear();
13238 slot.rotation_index = 0;
13239 }
13240 }
13241 self.state
13242 .push_log(format!("Deleted rotation: {preset_id}"));
13243 Ok(())
13244 }
13245
13246 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
13247 if !self.state.is_alive() {
13248 anyhow::bail!("you are dead");
13249 }
13250 let enabled = !self
13251 .state
13252 .combat_slots
13253 .iter()
13254 .find(|s| s.slot_index == slot_index)
13255 .map(|s| s.auto_enabled)
13256 .unwrap_or(false);
13257 self.seq += 1;
13258 self.session
13259 .submit_intent(Intent::SetAutoAttack {
13260 entity_id: self.state.entity_id,
13261 slot_index,
13262 enabled,
13263 seq: self.seq,
13264 })
13265 .await?;
13266 if slot_index == 1 {
13267 self.state.auto_attack = enabled;
13268 }
13269 self.state.intents_sent += 1;
13270 self.state.push_log(format!(
13271 "T{slot_index} auto {}",
13272 if enabled { "ON" } else { "OFF" }
13273 ));
13274 Ok(())
13275 }
13276
13277 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
13278 if !self.state.connected {
13279 anyhow::bail!("not connected");
13280 }
13281 if !self.state.is_alive() {
13282 anyhow::bail!("you are dead");
13283 }
13284 let (px, py) = self.state.player_position();
13285 if self
13286 .state
13287 .ground_drops
13288 .iter()
13289 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
13290 {
13291 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
13292 }
13293 self.seq += 1;
13294 self.session
13295 .submit_intent(Intent::Pickup {
13296 entity_id: self.state.entity_id,
13297 drop_id: None,
13298 seq: self.seq,
13299 })
13300 .await?;
13301 self.state.intents_sent += 1;
13302 Ok(())
13303 }
13304
13305 pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
13306 self.toggle_auto_attack_slot(1).await
13307 }
13308
13309 pub async fn dodge(&mut self) -> anyhow::Result<()> {
13310 if !self.state.is_alive() {
13311 anyhow::bail!("you are dead");
13312 }
13313 self.seq += 1;
13314 self.session
13315 .submit_intent(Intent::Dodge {
13316 entity_id: self.state.entity_id,
13317 seq: self.seq,
13318 })
13319 .await?;
13320 self.state.intents_sent += 1;
13321 self.state.push_log("Dodge!");
13322 Ok(())
13323 }
13324
13325 pub async fn lunge(&mut self) -> anyhow::Result<()> {
13326 if !self.state.is_alive() {
13327 anyhow::bail!("you are dead");
13328 }
13329 let (forward, strafe) = self.last_move_axes();
13330 self.seq += 1;
13331 self.session
13332 .submit_intent(Intent::Lunge {
13333 entity_id: self.state.entity_id,
13334 forward,
13335 strafe,
13336 seq: self.seq,
13337 })
13338 .await?;
13339 self.state.intents_sent += 1;
13340 self.state.push_log("Lunge!");
13341 Ok(())
13342 }
13343
13344 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
13345 if !self.state.is_alive() {
13346 anyhow::bail!("you are dead");
13347 }
13348 self.seq += 1;
13349 self.session
13350 .submit_intent(Intent::DirectionalJump {
13351 entity_id: self.state.entity_id,
13352 forward,
13353 strafe,
13354 seq: self.seq,
13355 })
13356 .await?;
13357 self.state.intents_sent += 1;
13358 self.state.push_log("Jump!");
13359 Ok(())
13360 }
13361
13362 pub fn last_move_axes(&self) -> (f32, f32) {
13364 (self.last_move_forward, self.last_move_strafe)
13365 }
13366
13367 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
13368 if !self.state.is_alive() {
13369 anyhow::bail!("you are dead");
13370 }
13371 self.seq += 1;
13372 self.session
13373 .submit_intent(Intent::Block {
13374 entity_id: self.state.entity_id,
13375 enabled,
13376 seq: self.seq,
13377 })
13378 .await?;
13379 self.state.intents_sent += 1;
13380 if enabled {
13381 self.state.push_log("Blocking");
13382 }
13383 Ok(())
13384 }
13385
13386 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
13387 if !self.state.is_alive() {
13388 anyhow::bail!("you are dead");
13389 }
13390 self.seq += 1;
13391 self.session
13392 .submit_intent(Intent::EquipMainhand {
13393 entity_id: self.state.entity_id,
13394 template_id,
13395 instance_id: None,
13396 seq: self.seq,
13397 })
13398 .await?;
13399 self.state.intents_sent += 1;
13400 Ok(())
13401 }
13402
13403 pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
13405 let idx = self.state.equip_menu_index;
13406 let slots = equip_paperdoll_rows(&self.state);
13407 let Some(row) = slots.get(idx) else {
13408 return Ok(());
13409 };
13410 match row {
13411 EquipPaperdollRow::Body { slot, filled } => {
13412 if *filled {
13413 self.equip_worn(*slot, None).await
13414 } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
13415 self.equip_worn(*slot, Some(inst)).await
13416 } else {
13417 self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
13418 Ok(())
13419 }
13420 }
13421 EquipPaperdollRow::Mainhand { filled } => {
13422 if *filled {
13423 self.unequip_mainhand().await
13424 } else if let Some(tid) = first_inventory_weapon(&self.state) {
13425 self.equip_mainhand(Some(tid)).await
13426 } else {
13427 self.state.push_log("No weapon in inventory".to_string());
13428 Ok(())
13429 }
13430 }
13431 EquipPaperdollRow::Offhand { filled, locked } => {
13432 if *locked {
13433 self.state
13434 .push_log("Offhand locked — two-handed weapon equipped".to_string());
13435 Ok(())
13436 } else if *filled {
13437 self.unequip_offhand().await
13438 } else if let Some(tid) = first_inventory_offhand(&self.state) {
13439 self.equip_offhand(Some(tid)).await
13440 } else {
13441 self.state
13442 .push_log("No offhand item in inventory".to_string());
13443 Ok(())
13444 }
13445 }
13446 }
13447 }
13448
13449 pub async fn say(
13450 &mut self,
13451 channel: flatland_protocol::ChatChannel,
13452 text: &str,
13453 ) -> anyhow::Result<()> {
13454 self.say_to(channel, text, None).await
13455 }
13456
13457 pub async fn say_to(
13458 &mut self,
13459 channel: flatland_protocol::ChatChannel,
13460 text: &str,
13461 to_entity: Option<EntityId>,
13462 ) -> anyhow::Result<()> {
13463 self.seq += 1;
13464 self.session
13465 .submit_intent(Intent::Say {
13466 entity_id: self.state.entity_id,
13467 channel,
13468 text: text.to_string(),
13469 to_entity,
13470 seq: self.seq,
13471 })
13472 .await?;
13473 self.state.intents_sent += 1;
13474 Ok(())
13475 }
13476
13477 pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
13478 let Some(peer) = self.state.player_verbs.target_entity else {
13479 return Ok(());
13480 };
13481 let label = self.state.player_verbs.target_label.clone();
13482 let choice = crate::social::PlayerVerbState::options()
13483 .get(self.state.player_verbs.index)
13484 .copied()
13485 .unwrap_or("Whisper");
13486 self.state.player_verbs.close();
13487 match choice {
13488 "Trade" => {
13489 self.seq += 1;
13492 self.session
13493 .submit_intent(Intent::TradeRequest {
13494 entity_id: self.state.entity_id,
13495 peer_entity_id: peer,
13496 seq: self.seq,
13497 })
13498 .await?;
13499 self.state.intents_sent += 1;
13500 self.state
13501 .social_chat
13502 .push_system(format!("Trade request sent to {label} — waiting for accept"));
13503 }
13504 "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
13505 _ => self.state.social_chat.focus_nearby(),
13506 }
13507 Ok(())
13508 }
13509
13510 pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
13511 let Some(pending) = self.state.social_chat.pending_trade.take() else {
13512 return Ok(());
13513 };
13514 self.seq += 1;
13515 self.session
13516 .submit_intent(Intent::TradeRespond {
13517 entity_id: self.state.entity_id,
13518 peer_entity_id: pending.from_entity,
13519 accept,
13520 seq: self.seq,
13521 })
13522 .await?;
13523 self.state.intents_sent += 1;
13524 if accept {
13525 self.state
13526 .social_chat
13527 .push_system(format!("Accepted trade with {}", pending.from_name));
13528 } else {
13529 self.state
13530 .social_chat
13531 .push_system(format!("Declined trade with {}", pending.from_name));
13532 }
13533 Ok(())
13534 }
13535
13536 pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
13537 let text = self.state.social_chat.buffer.trim().to_string();
13538 if text.is_empty() {
13539 return Ok(());
13540 }
13541 self.state.social_chat.buffer.clear();
13542 if crate::social::is_chat_slash_line(&text) {
13543 match crate::social::parse_chat_slash(&text) {
13544 Some(cmd) => return self.apply_chat_slash(cmd).await,
13545 None => {
13546 self.state.social_chat.push_system(format!(
13547 "Unknown command — {}",
13548 crate::social::chat_slash_help_text()
13549 ));
13550 return Ok(());
13551 }
13552 }
13553 }
13554 let thread = self.state.social_chat.thread;
13555 let channel = thread.channel();
13556 let to = thread.to_entity();
13557 if let Some(peer) = to {
13558 let label = self.state.social_chat.peer_label.clone();
13559 self.state
13560 .social_chat
13561 .remember_whisper_peer(peer, &label, channel);
13562 }
13563 self.say_to(channel, &text, to).await
13564 }
13565
13566 async fn apply_chat_slash(
13567 &mut self,
13568 cmd: crate::social::ChatSlashCommand,
13569 ) -> anyhow::Result<()> {
13570 use crate::social::{chat_slash_help_text, ChatSlashCommand};
13571 match cmd {
13572 ChatSlashCommand::Help => {
13573 self.state
13574 .social_chat
13575 .push_system(chat_slash_help_text().to_string());
13576 Ok(())
13577 }
13578 ChatSlashCommand::Nearby { message } => {
13579 self.state.social_chat.focus_nearby();
13580 self.state
13581 .social_chat
13582 .push_system("Nearby speech — everyone close can hear");
13583 if let Some(msg) = message {
13584 self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
13585 .await
13586 } else {
13587 Ok(())
13588 }
13589 }
13590 ChatSlashCommand::Reply { message } => {
13591 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
13592 self.state.social_chat.push_system(
13593 "No one to reply to — wait for a whisper, or /whisper Name",
13594 );
13595 return Ok(());
13596 };
13597 let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
13598 self.state
13599 .social_chat
13600 .set_whisper_thread(peer.entity_id, &peer.label, stone);
13601 self.state.social_chat.push_system(format!(
13602 "Replying to {} — type and Enter · /nearby",
13603 peer.label
13604 ));
13605 if let Some(msg) = message {
13606 self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
13607 } else {
13608 Ok(())
13609 }
13610 }
13611 ChatSlashCommand::Whisper { name, message } => {
13612 let (peer_id, label, stone) = if let Some(name) = name {
13613 match self.resolve_whisper_target(&name) {
13614 Ok(t) => t,
13615 Err(err) => {
13616 self.state.social_chat.push_system(err);
13617 return Ok(());
13618 }
13619 }
13620 } else {
13621 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
13622 self.state.social_chat.push_system(
13623 "Usage: /whisper Name [message] · or /reply after someone whispers you",
13624 );
13625 return Ok(());
13626 };
13627 (
13628 peer.entity_id,
13629 peer.label,
13630 peer.channel == flatland_protocol::ChatChannel::WhisperStone,
13631 )
13632 };
13633 self.state
13634 .social_chat
13635 .set_whisper_thread(peer_id, &label, stone);
13636 let channel = if stone {
13637 flatland_protocol::ChatChannel::WhisperStone
13638 } else {
13639 flatland_protocol::ChatChannel::Whisper
13640 };
13641 if let Some(msg) = message {
13642 self.state.social_chat.push_system(format!(
13643 "Whisper → {label}"
13644 ));
13645 self.say_to(channel, &msg, Some(peer_id)).await
13646 } else {
13647 self.state.social_chat.push_system(format!(
13648 "Whispering {label} — type and Enter · Esc / /nearby cancels"
13649 ));
13650 Ok(())
13651 }
13652 }
13653 }
13654 }
13655
13656 fn resolve_whisper_target(
13658 &self,
13659 name: &str,
13660 ) -> Result<(EntityId, String, bool), String> {
13661 let needle = name.trim().to_ascii_lowercase();
13662 if needle.is_empty() {
13663 return Err("Usage: /whisper Name [message]".into());
13664 }
13665 let mut candidates: Vec<(EntityId, String)> = self
13666 .state
13667 .entities
13668 .iter()
13669 .filter(|e| e.id != self.state.entity_id)
13670 .filter(|e| !e.label.trim().is_empty())
13671 .filter(|e| e.vitals.is_some())
13672 .filter(|e| {
13673 !self
13674 .state
13675 .npcs
13676 .iter()
13677 .any(|n| n.id == e.id.to_string())
13678 })
13679 .filter(|e| {
13680 !self
13681 .state
13682 .hired_workers
13683 .iter()
13684 .any(|w| w.entity_id == e.id)
13685 })
13686 .map(|e| (e.id, e.label.clone()))
13687 .collect();
13688
13689 if let Some(last) = &self.state.social_chat.last_whisper_peer {
13691 if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
13692 candidates.push((last.entity_id, last.label.clone()));
13693 }
13694 }
13695
13696 let exact: Vec<_> = candidates
13697 .iter()
13698 .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
13699 .cloned()
13700 .collect();
13701 let pool = if exact.len() == 1 {
13702 exact
13703 } else if exact.len() > 1 {
13704 return Err(format!(
13705 "Several players named '{name}' nearby — move closer and try again"
13706 ));
13707 } else {
13708 let starts: Vec<_> = candidates
13709 .iter()
13710 .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
13711 .cloned()
13712 .collect();
13713 if starts.len() == 1 {
13714 starts
13715 } else if starts.len() > 1 {
13716 let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
13717 return Err(format!(
13718 "Ambiguous name '{name}' — matches: {}",
13719 names.join(", ")
13720 ));
13721 } else {
13722 let contains: Vec<_> = candidates
13723 .iter()
13724 .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
13725 .cloned()
13726 .collect();
13727 if contains.len() == 1 {
13728 contains
13729 } else if contains.is_empty() {
13730 return Err(format!(
13731 "No player matching '{name}' in range — get closer or check the spelling"
13732 ));
13733 } else {
13734 let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
13735 return Err(format!(
13736 "Ambiguous name '{name}' — matches: {}",
13737 names.join(", ")
13738 ));
13739 }
13740 }
13741 };
13742
13743 let (id, label) = pool.into_iter().next().unwrap();
13744 let stone = self
13745 .state
13746 .social_chat
13747 .last_whisper_peer
13748 .as_ref()
13749 .is_some_and(|p| p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone);
13750 Ok((id, label, stone))
13751 }
13752
13753 pub async fn trade_present_selected(
13754 &mut self,
13755 item_instance_id: uuid::Uuid,
13756 ) -> anyhow::Result<()> {
13757 self.trade_present_quantity(item_instance_id, None).await
13758 }
13759
13760 pub async fn trade_present_quantity(
13761 &mut self,
13762 item_instance_id: uuid::Uuid,
13763 quantity: Option<u32>,
13764 ) -> anyhow::Result<()> {
13765 self.seq += 1;
13766 self.session
13767 .submit_intent(Intent::TradePresent {
13768 entity_id: self.state.entity_id,
13769 item_instance_id,
13770 quantity,
13771 seq: self.seq,
13772 })
13773 .await?;
13774 self.state.intents_sent += 1;
13775 self.state.trade_ui.qty_entry = None;
13776 self.state.trade_ui.picking_inventory = false;
13777 Ok(())
13778 }
13779
13780 pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
13782 if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
13783 let qty = self.state.trade_ui.present_quantity();
13784 return self
13785 .trade_present_quantity(entry.item_instance_id, qty)
13786 .await;
13787 }
13788 if !self.state.trade_ui.picking_inventory {
13789 return Ok(());
13790 }
13791 let Some(stack) = self
13792 .state
13793 .inventory_stacks
13794 .get(self.state.trade_ui.inventory_index)
13795 .cloned()
13796 else {
13797 return Ok(());
13798 };
13799 let Some(id) = stack.item_instance_id else {
13800 return Ok(());
13801 };
13802 let label = stack
13803 .display_name
13804 .clone()
13805 .unwrap_or_else(|| stack.template_id.clone());
13806 if stack.quantity <= 1 {
13807 self.trade_present_quantity(id, Some(1)).await
13808 } else {
13809 self.state
13810 .trade_ui
13811 .begin_qty_entry(id, label, stack.quantity);
13812 Ok(())
13813 }
13814 }
13815
13816 pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
13817 self.seq += 1;
13818 self.session
13819 .submit_intent(Intent::TradeSetReady {
13820 entity_id: self.state.entity_id,
13821 ready,
13822 seq: self.seq,
13823 })
13824 .await?;
13825 self.state.intents_sent += 1;
13826 Ok(())
13827 }
13828
13829 pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
13830 self.seq += 1;
13831 self.session
13832 .submit_intent(Intent::TradeCancel {
13833 entity_id: self.state.entity_id,
13834 seq: self.seq,
13835 })
13836 .await?;
13837 self.state.intents_sent += 1;
13838 self.state.trade_ui.close();
13839 Ok(())
13840 }
13841
13842 pub async fn destroy_whisper_stone(
13843 &mut self,
13844 item_instance_id: uuid::Uuid,
13845 ) -> anyhow::Result<()> {
13846 self.seq += 1;
13847 self.session
13848 .submit_intent(Intent::DestroyWhisperStone {
13849 entity_id: self.state.entity_id,
13850 item_instance_id,
13851 seq: self.seq,
13852 })
13853 .await?;
13854 self.state.intents_sent += 1;
13855 Ok(())
13856 }
13857
13858 pub async fn stop(&mut self) -> anyhow::Result<()> {
13859 self.seq += 1;
13860 self.session
13861 .submit_intent(Intent::Stop {
13862 entity_id: self.state.entity_id,
13863 seq: self.seq,
13864 })
13865 .await?;
13866 self.state.intents_sent += 1;
13867 Ok(())
13868 }
13869
13870 pub fn disconnect(&self) {
13871 self.session.disconnect();
13872 }
13873}
13874
13875fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
13876 let dx = ax - bx;
13877 let dy = ay - by;
13878 (dx * dx + dy * dy).sqrt()
13879}
13880
13881#[cfg(test)]
13882mod tests {
13883 use std::collections::BTreeMap;
13884
13885 use super::*;
13886 use flatland_protocol::{
13887 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
13888 };
13889
13890 fn sample_state() -> GameState {
13891 let mut state = GameState {
13892 session_id: 1,
13893 entity_id: 1,
13894 character_id: None,
13895 tick: 0,
13896 chunk_rev: 0,
13897 content_rev: 0,
13898 publish_rev: 0,
13899 entities: vec![EntityState {
13900 id: 1,
13901 label: "You".into(),
13902 transform: Transform {
13903 position: WorldCoord::surface(128.0, 128.0),
13904 yaw: 0.0,
13905 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
13906 },
13907 vitals: None,
13908 attributes: None,
13909 skills: None,
13910 inside_building: None,
13911 tile_id: None,
13912 paperdoll_ref: None,
13913 presentation_state: None,
13914 sprite_mode: None,
13915 progression_xp: None,
13916 combat_cues: vec![],
13917 }],
13918 player: None,
13919 resource_nodes: vec![ResourceNodeView {
13920 id: "oak-1".into(),
13921 label: "Oak".into(),
13922 x: 130.0,
13923 y: 128.0,
13924 z: 0.0,
13925 item_template: "oak_log".into(),
13926 state: ResourceNodeState::Available,
13927 blocking: true,
13928 blocking_radius_m: 0.8,
13929 tile_id: None,
13930 yaw: 0.0,
13931 pitch: 0.0,
13932 roll: 0.0,
13933 draw_scale: 1.0,
13934 sprite_mode: None,
13935 growth_progress: None,
13936 presentation_state: None,
13937 channel_start_tick: None,
13938 channel_end_tick: None,
13939 harvest_drop_templates: vec![],
13940 }],
13941 ground_drops: vec![],
13942 placed_containers: vec![],
13943 buildings: vec![BuildingView {
13944 id: "broker-hut".into(),
13945 label: "Broker".into(),
13946 x: 148.0,
13947 y: 118.0,
13948 width_m: 8.0,
13949 depth_m: 6.0,
13950 interior_blueprint: Some("broker_hut".into()),
13951 tags: vec![],
13952 market_boundary_zone_ids: vec![],
13953 market_max_volume: None,
13954 wall_set: None,
13955 roof_set: None,
13956 }],
13957 doors: vec![flatland_protocol::DoorView {
13958 id: "door-1".into(),
13959 building_id: "broker-hut".into(),
13960 x: 148.0,
13961 y: 118.0,
13962 open: false,
13963 portal: Some("front".into()),
13964 }],
13965 interior_map: None,
13966 npcs: vec![],
13967 blueprints: vec![],
13968 world_x0: 0.0,
13969 world_y0: 0.0,
13970 world_width_m: 256.0,
13971 world_height_m: 256.0,
13972 terrain_zones: Vec::new(),
13973 z_platforms: Vec::new(),
13974 z_transitions: Vec::new(),
13975 z_bands_outdoor_backup: None,
13976 world_clock: flatland_protocol::WorldClock::default(),
13977 inventory: std::collections::HashMap::new(),
13978 inventory_hints: std::collections::HashMap::new(),
13979 logs: VecDeque::new(),
13980 intents_sent: 0,
13981 ticks_received: 0,
13982 connected: true,
13983 disconnect_reason: None,
13984 show_stats: false,
13985 hud_log_hidden: false,
13986 show_equip_menu: false,
13987 equip_menu_index: 0,
13988 show_craft_menu: false,
13989 craft_menu_index: 0,
13990 craft_batch_quantity: 1,
13991 show_shop_menu: false,
13992 shop_catalog: None,
13993 bank_panel: None,
13994 bank_menu_index: 0,
13995 bank_ui_mode: BankUiMode::Menu,
13996 storage_panel: None,
13997 market_panel: None,
13998 market_menu_index: 0,
13999 market_filter: String::new(),
14000 market_filter_focused: false,
14001 market_category_filter: None,
14002 market_buy_confirm: None,
14003 market_ui_mode: MarketUiMode::Browse,
14004 storage_menu_index: 0,
14005 storage_ui_mode: StorageUiMode::Menu,
14006 shop_tab: ShopTab::default(),
14007 shop_menu_index: 0,
14008 shop_quantity: 1,
14009 shop_trade_log: VecDeque::new(),
14010 show_npc_verb_menu: false,
14011 npc_verb_target: None,
14012 npc_verb_index: 0,
14013 player_verbs: crate::social::PlayerVerbState::default(),
14014 social_chat: crate::social::SocialChatState::default(),
14015 trade_ui: crate::social::TradeUiState::default(),
14016 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
14017 show_npc_chat: false,
14018 npc_chat: None,
14019 show_inventory_menu: false,
14020 inventory_menu_index: 0,
14021 inventory_tab: InventoryTab::OnPerson,
14022 inventory_filter: String::new(),
14023 inventory_filter_focused: false,
14024 show_move_picker: false,
14025 show_rename_prompt: false,
14026 show_worker_rename: false,
14027 rename_buffer: String::new(),
14028 move_picker_index: 0,
14029 move_picker: None,
14030 show_grant_picker: false,
14031 grant_picker_index: 0,
14032 grant_picker: None,
14033 show_destroy_picker: false,
14034 destroy_confirm_pending: false,
14035 destroy_picker: None,
14036 combat_target: None,
14037 combat_target_label: None,
14038 ground_target: None,
14039 combat_fx: Vec::new(),
14040 property_zones: Vec::new(),
14041 tax_zones: Vec::new(),
14042 growth_zones: Vec::new(),
14043 biome_zones: Vec::new(),
14044 property_plots: Vec::new(),
14045 property_plot_settings: None,
14046 claim_mode: None,
14047 relocate_mode: None,
14048 sell_plot_confirm: None,
14049 sell_plot_armed_at: None,
14050 show_plant_menu: false,
14051 plant_menu_index: 0,
14052 show_farm_access: false,
14053 farm_access_name_draft: String::new(),
14054 farm_access_discount_bps: 0,
14055 farm_access_index: 0,
14056 plant_quantity: 1,
14057 in_combat: false,
14058 auto_attack: true,
14059 combat_has_los: false,
14060 attack_cd_ticks: 0,
14061 gcd_ticks: 0,
14062 weapon_ability_id: "unarmed".into(),
14063 mainhand_template_id: None,
14064 mainhand_label: None,
14065 offhand_template_id: None,
14066 offhand_label: None,
14067 mainhand_hand_slots: 1,
14068 defense: None,
14069 worn: BTreeMap::new(),
14070 carry_mass: 0.0,
14071 carry_mass_max: 0.0,
14072 encumbrance: flatland_protocol::EncumbranceState::Light,
14073 inventory_stacks: Vec::new(),
14074 keychain_stacks: Vec::new(),
14075 whisper_pouch_stacks: Vec::new(),
14076 combat_target_detail: None,
14077 statuses: Vec::new(),
14078 cast_progress: None,
14079 timed_channel: None,
14080 ability_cooldowns: Vec::new(),
14081 blocking_active: false,
14082 max_target_slots: 1,
14083 combat_slots: Vec::new(),
14084 rotation_presets: Vec::new(),
14085 known_abilities: Vec::new(),
14086 ability_meta: std::collections::HashMap::new(),
14087 hotbar: vec![None; 9],
14088 max_abilities_per_rotation: 0,
14089 show_loadout_menu: false,
14090 show_keychain_menu: false,
14091 keychain_menu_index: 0,
14092 show_rotation_editor: false,
14093 loadout_menu_index: 0,
14094 loadout_hotbar_slot: 1,
14095 loadout_ability_index: 0,
14096 loadout_focus_presets: false,
14097 rotation_editor: RotationEditorState::default(),
14098 harvest_in_progress: false,
14099 harvest_started_at: None,
14100 pending_craft_ack: None,
14101 pending_worker_job_ack: None,
14102 attending_worker_instance_id: None,
14103 quest_log: Vec::new(),
14104 interactables: Vec::new(),
14105 ledger: None,
14106 career: None,
14107 character_sheet_tab: CharacterSheetTab::Character,
14108 ledger_period: LedgerPeriod::Day,
14109 show_quest_offer: false,
14110 pending_quest_offer: None,
14111 show_quest_menu: false,
14112 quest_menu_index: 0,
14113 quest_withdraw_confirm: false,
14114 hired_workers: Vec::new(),
14115 show_workers_menu: false,
14116 workers_menu_index: 0,
14117 workers_menu_compact: false,
14118 worker_step_display: BTreeMap::new(),
14119 worker_error_display: BTreeMap::new(),
14120 show_worker_give_picker: false,
14121 worker_give_picker_index: 0,
14122 worker_give_picker: None,
14123 show_worker_give_target_picker: false,
14124 worker_give_target_picker_index: 0,
14125 worker_give_target_picker: None,
14126 show_worker_take_picker: false,
14127 worker_take_picker_index: 0,
14128 worker_take_picker: None,
14129 show_worker_teach_picker: false,
14130 worker_teach_picker_index: 0,
14131 worker_teach_picker: None,
14132 worker_route_editor: None,
14133 progression_curve: None,
14134 };
14135 state.player = state.entities.first().cloned();
14136 state
14137 }
14138
14139 #[test]
14140 fn whisper_cancels_when_peer_walks_out_of_range() {
14141 let mut state = sample_state();
14142 state.player = state.entities.first().cloned();
14143 let mut peer = state.entities[0].clone();
14144 peer.id = 2;
14145 peer.label = "Ada".into();
14146 peer.transform.position = WorldCoord::surface(129.0, 128.0); state.entities.push(peer.clone());
14148 state.social_chat.focus_whisper(2, "Ada");
14149 state.refresh_whisper_range();
14150 assert!(matches!(
14151 state.social_chat.thread,
14152 crate::social::ChatThreadKind::Whisper { peer: 2 }
14153 ));
14154
14155 peer.transform.position = WorldCoord::surface(132.0, 128.0); state.entities[1] = peer;
14157 state.refresh_whisper_range();
14158 assert_eq!(
14159 state.social_chat.thread,
14160 crate::social::ChatThreadKind::Nearby
14161 );
14162 assert!(!state.social_chat.input_focused);
14163 }
14164
14165 #[test]
14166 fn probe_use_world_hired_worker_manage() {
14167 let mut state = sample_state();
14168 state.hired_workers.push(flatland_protocol::HiredWorkerView {
14169 instance_id: "worker-1".into(),
14170 entity_id: 42,
14171 def_id: "worker_laborer".into(),
14172 label: "Sam".into(),
14173 x: 129.0,
14174 y: 128.0,
14175 z: 0.0,
14176 mode: flatland_protocol::WorkerModeView::JobLoop,
14177 state: flatland_protocol::WorkerStateView::Working,
14178 step_label: "cultivate".into(),
14179 vitals: flatland_protocol::WorkerVitalsSummary {
14180 health_pct: 100.0,
14181 stamina_pct: 100.0,
14182 },
14183 carry_pct: 0.0,
14184 last_error: None,
14185 wage_copper_per_interval: 1,
14186 effective_wage_copper: 1,
14187 wage_meters_walked: 0.0,
14188 lodging_container_id: None,
14189 route: None,
14190 route_stop_index: None,
14191 known_blueprint_ids: Vec::new(),
14192 level: 1,
14193 worker_xp: 0.0,
14194 inventory: Vec::new(),
14195 });
14196 let probe = state.probe_use_world();
14197 let primary = probe.primary.expect("primary");
14198 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
14199 assert_eq!(primary.id, "worker-1");
14200 assert!(primary.hint_line().contains("Manage"));
14201 assert!(primary.hint_line().contains("Sam"));
14202 assert_eq!(
14203 state.nearest_interact_target().as_deref(),
14204 Some("worker-1")
14205 );
14206 }
14207
14208 #[test]
14209 fn market_clerk_verb_options_include_market() {
14210 let mut state = sample_state();
14211 state.npcs.push(flatland_protocol::NpcView {
14212 id: "mira_market".into(),
14213 label: "Mira".into(),
14214 role: "market_clerk".into(),
14215 x: 129.0,
14216 y: 128.0,
14217 building_id: Some("town_market".into()),
14218 entity_id: None,
14219 life_state: None,
14220 hp_pct: None,
14221 can_trade: false,
14222 tile_id: None,
14223 behavior_state: None,
14224 presentation_state: None,
14225 sprite_mode: None,
14226 paperdoll_ref: None,
14227 });
14228 state.npc_verb_target = Some("mira_market".into());
14229 assert_eq!(state.npc_verb_options(), vec!["Market", "Talk"]);
14230 }
14231
14232 #[test]
14233 fn market_list_excludes_currency_stacks() {
14234 let mut state = sample_state();
14235 state.inventory_stacks = vec![
14236 flatland_protocol::ItemStack {
14237 template_id: "copper_coin".into(),
14238 quantity: 50,
14239 item_instance_id: Some(uuid::Uuid::from_u128(10)),
14240 display_name: Some("Copper Coin".into()),
14241 ..Default::default()
14242 },
14243 flatland_protocol::ItemStack {
14244 template_id: "oak_log".into(),
14245 quantity: 2,
14246 item_instance_id: Some(uuid::Uuid::from_u128(11)),
14247 display_name: Some("Oak Log".into()),
14248 ..Default::default()
14249 },
14250 flatland_protocol::ItemStack {
14251 template_id: "whisper_stone".into(),
14252 quantity: 1,
14253 item_instance_id: Some(uuid::Uuid::from_u128(12)),
14254 display_name: Some("Whisper Stone".into()),
14255 category: Some("quest".into()),
14256 listable: Some(false),
14257 ..Default::default()
14258 },
14259 ];
14260 let opts = state.market_list_item_options(&MarketListSourceKind::Person);
14261 assert_eq!(opts.len(), 1);
14262 assert!(opts[0].label.contains("Oak"));
14263 }
14264
14265 #[test]
14266 fn market_browse_filters_by_category_and_search() {
14267 let mut state = sample_state();
14268 state.market_panel = Some(flatland_protocol::MarketPanel {
14269 npc_id: "mira_market".into(),
14270 npc_label: "Mira".into(),
14271 building_id: "town_market".into(),
14272 building_label: "Town Market".into(),
14273 used_volume: 0.0,
14274 max_volume: 100.0,
14275 listings: vec![
14276 flatland_protocol::MarketListingView {
14277 listing_id: uuid::Uuid::from_u128(1),
14278 seller_character_id: uuid::Uuid::from_u128(2),
14279 seller_label: "Ada".into(),
14280 hall_building_id: "town_market".into(),
14281 hall_label: "Town Market".into(),
14282 template_id: "oak_log".into(),
14283 display_name: "Oak Log".into(),
14284 category: "resource".into(),
14285 quantity: 3,
14286 unit_price_copper: 10,
14287 line_total_copper: 30,
14288 mine: false,
14289 },
14290 flatland_protocol::MarketListingView {
14291 listing_id: uuid::Uuid::from_u128(3),
14292 seller_character_id: uuid::Uuid::from_u128(2),
14293 seller_label: "Ada".into(),
14294 hall_building_id: "town_market".into(),
14295 hall_label: "Town Market".into(),
14296 template_id: "short_sword".into(),
14297 display_name: "Short Sword".into(),
14298 category: "weapon".into(),
14299 quantity: 1,
14300 unit_price_copper: 100,
14301 line_total_copper: 100,
14302 mine: false,
14303 },
14304 ],
14305 tax_bps: 0,
14306 tax_flat_copper: 0,
14307 list_vaults: vec![],
14308 });
14309 assert_eq!(state.market_filtered_listing_indices().len(), 2);
14310 state.market_category_filter = Some("Weapons");
14311 let weapons = state.market_filtered_listing_indices();
14312 assert_eq!(weapons.len(), 1);
14313 assert_eq!(
14314 state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
14315 "Short Sword"
14316 );
14317 state.market_category_filter = None;
14318 state.market_filter = "oak".into();
14319 let oak = state.market_filtered_listing_indices();
14320 assert_eq!(oak.len(), 1);
14321 assert_eq!(
14322 state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
14323 "Oak Log"
14324 );
14325 }
14326
14327 #[test]
14328 fn market_list_source_includes_person_and_vaults() {
14329 let mut state = sample_state();
14330 let item_id = uuid::Uuid::from_u128(1);
14331 state.inventory_stacks = vec![flatland_protocol::ItemStack {
14332 template_id: "oak_log".into(),
14333 quantity: 2,
14334 item_instance_id: Some(item_id),
14335 display_name: Some("Oak Log".into()),
14336 ..Default::default()
14337 }];
14338 state.market_panel = Some(flatland_protocol::MarketPanel {
14339 npc_id: "mira_market".into(),
14340 npc_label: "Mira".into(),
14341 building_id: "town_market".into(),
14342 building_label: "Town Market".into(),
14343 used_volume: 0.0,
14344 max_volume: 100.0,
14345 listings: vec![],
14346 tax_bps: 0,
14347 tax_flat_copper: 0,
14348 list_vaults: vec![flatland_protocol::MarketListVault {
14349 building_id: "town_storage".into(),
14350 building_label: "Town Storage".into(),
14351 contents: vec![flatland_protocol::ItemStack {
14352 template_id: "lumber".into(),
14353 quantity: 1,
14354 item_instance_id: Some(uuid::Uuid::from_u128(2)),
14355 display_name: Some("Lumber".into()),
14356 ..Default::default()
14357 }],
14358 }],
14359 });
14360 let sources = state.market_list_source_options();
14361 assert_eq!(sources.len(), 2);
14362 assert!(matches!(sources[0].0, MarketListSourceKind::Person));
14363 assert!(matches!(
14364 sources[1].0,
14365 MarketListSourceKind::TownStorage { .. }
14366 ));
14367 assert!(sources[1].1.contains("Town Storage"));
14368 }
14369
14370 #[test]
14371 fn probe_use_world_npc_beats_nearby_loot() {
14372 let mut state = sample_state();
14373 state.npcs.push(flatland_protocol::NpcView {
14374 id: "ada".into(),
14375 label: "Ada".into(),
14376 role: "broker".into(),
14377 x: 129.0,
14378 y: 128.0,
14379 building_id: None,
14380 entity_id: None,
14381 life_state: None,
14382 hp_pct: None,
14383 can_trade: true,
14384 tile_id: None,
14385 behavior_state: None,
14386 presentation_state: None,
14387 sprite_mode: None,
14388 paperdoll_ref: None,
14389 });
14390 state.ground_drops.push(flatland_protocol::GroundDropView {
14391 id: "d1".into(),
14392 template_id: "lumber".into(),
14393 quantity: 1,
14394 x: 128.5,
14395 y: 128.0,
14396 z: 0.0,
14397 tile_id: None,
14398 display_name: None,
14399 yaw: 0.0,
14400 pitch: 0.0,
14401 roll: 0.0,
14402 draw_scale: 1.0,
14403 });
14404 let probe = state.probe_use_world();
14405 let primary = probe.primary.expect("primary");
14406 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
14407 assert_eq!(primary.id, "ada");
14408 }
14409
14410 #[test]
14411 fn probe_use_world_harvest_when_in_range() {
14412 let state = sample_state(); let probe = state.probe_use_world();
14414 assert!(
14415 probe.primary.is_none(),
14416 "oak is 2m away, out of harvest range"
14417 );
14418 assert!(probe
14419 .candidates
14420 .iter()
14421 .any(|c| c.kind == crate::UseWorldKind::Harvest));
14422
14423 let mut state = sample_state();
14424 state.resource_nodes[0].x = 129.0;
14425 let probe = state.probe_use_world();
14426 let primary = probe.primary.expect("primary");
14427 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
14428 }
14429
14430 #[test]
14431 fn probe_use_world_door_uses_building_label() {
14432 let mut state = sample_state();
14433 state.doors[0].x = 129.0;
14434 state.doors[0].y = 128.0;
14435 let probe = state.probe_use_world();
14436 let primary = probe.primary.expect("primary");
14437 assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
14438 assert_eq!(primary.label, "Broker");
14439 assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
14440 }
14441
14442 #[test]
14443 fn empty_entity_tick_preserves_welcome_snapshot() {
14444 let mut state = sample_state();
14445 state.inventory.insert("carrot".into(), 3);
14446 let delta = TickDelta {
14447 tick: 1,
14448 entities: vec![],
14449 resource_nodes: vec![],
14450 ground_drops: vec![],
14451 placed_containers: vec![],
14452 buildings: vec![],
14453 doors: vec![],
14454 interior_map: None,
14455 npcs: vec![],
14456 inventory: vec![],
14457 blueprints: vec![],
14458 world_clock: flatland_protocol::WorldClock::default(),
14459 combat: None,
14460 quest_log: vec![],
14461 hired_workers: Vec::new(),
14462 interactables: vec![],
14463 ledger: None,
14464 career: None,
14465 combat_fx: Vec::new(),
14466 property_plots: Vec::new(),
14467 terrain_overlays: Vec::new(),
14468 };
14469
14470 state.apply_tick_fields(&delta, 1);
14471
14472 assert_eq!(state.entities.len(), 1);
14473 assert!(state.player.is_some());
14474 assert_eq!(state.inventory.get("carrot"), Some(&3));
14475 assert_eq!(state.resource_nodes.len(), 1);
14476 }
14477
14478 #[test]
14479 fn tick_preserves_world_layers_when_delta_omits_them() {
14480 let mut state = sample_state();
14481 let delta = TickDelta {
14482 tick: 1,
14483 entities: state.entities.clone(),
14484 resource_nodes: vec![],
14485 ground_drops: vec![],
14486 placed_containers: vec![],
14487 buildings: vec![],
14488 doors: vec![],
14489 interior_map: None,
14490 npcs: vec![],
14491 inventory: vec![],
14492 blueprints: vec![],
14493 world_clock: flatland_protocol::WorldClock::default(),
14494 combat: None,
14495 quest_log: vec![],
14496 hired_workers: Vec::new(),
14497 interactables: vec![],
14498 ledger: None,
14499 career: None,
14500 combat_fx: Vec::new(),
14501 property_plots: Vec::new(),
14502 terrain_overlays: Vec::new(),
14503 };
14504
14505 state.apply_tick_fields(&delta, 1);
14506
14507 assert_eq!(state.resource_nodes.len(), 1);
14508 assert_eq!(state.buildings.len(), 1);
14509 assert_eq!(state.doors.len(), 1);
14510 }
14511
14512 #[test]
14513 fn tick_updates_resource_nodes_when_server_sends_them() {
14514 let mut state = sample_state();
14515 let delta = TickDelta {
14516 tick: 1,
14517 entities: state.entities.clone(),
14518 resource_nodes: vec![ResourceNodeView {
14519 id: "oak-1".into(),
14520 label: "Oak".into(),
14521 x: 130.0,
14522 y: 128.0,
14523 z: 0.0,
14524 item_template: "oak_log".into(),
14525 state: ResourceNodeState::Cooldown,
14526 blocking: true,
14527 blocking_radius_m: 0.8,
14528 tile_id: None,
14529 yaw: 0.0,
14530 pitch: 0.0,
14531 roll: 0.0,
14532 draw_scale: 1.0,
14533 sprite_mode: None,
14534 growth_progress: None,
14535 presentation_state: None,
14536 channel_start_tick: None,
14537 channel_end_tick: None,
14538 harvest_drop_templates: vec![],
14539 }],
14540 buildings: vec![],
14541 doors: vec![],
14542 interior_map: None,
14543 npcs: vec![],
14544 inventory: vec![],
14545 blueprints: vec![],
14546 world_clock: flatland_protocol::WorldClock::default(),
14547 ground_drops: vec![],
14548 placed_containers: vec![],
14549 combat: None,
14550 quest_log: vec![],
14551 hired_workers: Vec::new(),
14552 interactables: vec![],
14553 ledger: None,
14554 career: None,
14555 combat_fx: Vec::new(),
14556 property_plots: Vec::new(),
14557 terrain_overlays: Vec::new(),
14558 };
14559
14560 state.apply_tick_fields(&delta, 1);
14561
14562 assert!(matches!(
14563 state.resource_nodes[0].state,
14564 ResourceNodeState::Cooldown
14565 ));
14566 }
14567
14568 #[test]
14569 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
14570 let mut state = GameState {
14571 session_id: 1,
14572 entity_id: 1,
14573 character_id: None,
14574 tick: 0,
14575 chunk_rev: 0,
14576 content_rev: 0,
14577 publish_rev: 0,
14578 entities: vec![EntityState {
14579 id: 1,
14580 label: "You".into(),
14581 transform: Transform {
14582 position: WorldCoord::surface(4.5, 2.0),
14583 yaw: 0.0,
14584 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
14585 },
14586 vitals: None,
14587 attributes: None,
14588 skills: None,
14589 inside_building: Some("broker_hut".into()),
14590 tile_id: None,
14591 paperdoll_ref: None,
14592 presentation_state: None,
14593 sprite_mode: None,
14594 progression_xp: None,
14595 combat_cues: vec![],
14596 }],
14597 player: None,
14598 resource_nodes: vec![],
14599 ground_drops: vec![],
14600 placed_containers: vec![],
14601 buildings: vec![BuildingView {
14602 id: "broker_hut".into(),
14603 label: "Broker".into(),
14604 x: 158.0,
14605 y: 124.0,
14606 width_m: 8.0,
14607 depth_m: 6.0,
14608 interior_blueprint: Some("broker_hut".into()),
14609 tags: vec![],
14610 market_boundary_zone_ids: vec![],
14611 market_max_volume: None,
14612 wall_set: None,
14613 roof_set: None,
14614 }],
14615 doors: vec![flatland_protocol::DoorView {
14616 id: "broker_hut_exit".into(),
14617 building_id: "broker_hut".into(),
14618 x: 4.3,
14619 y: 0.9,
14620 open: true,
14621 portal: Some("front".into()),
14622 }],
14623 interior_map: None,
14624 npcs: vec![flatland_protocol::NpcView {
14625 id: "ada_broker".into(),
14626 label: "Ada".into(),
14627 x: 4.5,
14628 y: 2.0,
14629 building_id: Some("broker_hut".into()),
14630 role: "broker".into(),
14631 entity_id: None,
14632 life_state: None,
14633 hp_pct: None,
14634 can_trade: true,
14635 tile_id: None,
14636 behavior_state: None,
14637 presentation_state: None,
14638 sprite_mode: None,
14639 paperdoll_ref: None,
14640 }],
14641 blueprints: vec![],
14642 world_x0: 0.0,
14643 world_y0: 0.0,
14644 world_width_m: 256.0,
14645 world_height_m: 256.0,
14646 terrain_zones: Vec::new(),
14647 z_platforms: Vec::new(),
14648 z_transitions: Vec::new(),
14649 z_bands_outdoor_backup: None,
14650 world_clock: flatland_protocol::WorldClock::default(),
14651 inventory: std::collections::HashMap::new(),
14652 inventory_hints: std::collections::HashMap::new(),
14653 logs: VecDeque::new(),
14654 intents_sent: 0,
14655 ticks_received: 0,
14656 connected: true,
14657 disconnect_reason: None,
14658 show_stats: false,
14659 hud_log_hidden: false,
14660 show_equip_menu: false,
14661 equip_menu_index: 0,
14662 show_craft_menu: false,
14663 craft_menu_index: 0,
14664 craft_batch_quantity: 1,
14665 show_shop_menu: false,
14666 shop_catalog: None,
14667 bank_panel: None,
14668 bank_menu_index: 0,
14669 bank_ui_mode: BankUiMode::Menu,
14670 storage_panel: None,
14671 market_panel: None,
14672 market_menu_index: 0,
14673 market_filter: String::new(),
14674 market_filter_focused: false,
14675 market_category_filter: None,
14676 market_buy_confirm: None,
14677 market_ui_mode: MarketUiMode::Browse,
14678 storage_menu_index: 0,
14679 storage_ui_mode: StorageUiMode::Menu,
14680 shop_tab: ShopTab::default(),
14681 shop_menu_index: 0,
14682 shop_quantity: 1,
14683 shop_trade_log: VecDeque::new(),
14684 show_npc_verb_menu: false,
14685 npc_verb_target: None,
14686 npc_verb_index: 0,
14687 player_verbs: crate::social::PlayerVerbState::default(),
14688 social_chat: crate::social::SocialChatState::default(),
14689 trade_ui: crate::social::TradeUiState::default(),
14690 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
14691 show_npc_chat: false,
14692 npc_chat: None,
14693 show_inventory_menu: false,
14694 inventory_menu_index: 0,
14695 inventory_tab: InventoryTab::OnPerson,
14696 inventory_filter: String::new(),
14697 inventory_filter_focused: false,
14698 show_move_picker: false,
14699 show_rename_prompt: false,
14700 show_worker_rename: false,
14701 rename_buffer: String::new(),
14702 move_picker_index: 0,
14703 move_picker: None,
14704 show_grant_picker: false,
14705 grant_picker_index: 0,
14706 grant_picker: None,
14707 show_destroy_picker: false,
14708 destroy_confirm_pending: false,
14709 destroy_picker: None,
14710 combat_target: None,
14711 combat_target_label: None,
14712 ground_target: None,
14713 combat_fx: Vec::new(),
14714 property_zones: Vec::new(),
14715 tax_zones: Vec::new(),
14716 growth_zones: Vec::new(),
14717 biome_zones: Vec::new(),
14718 property_plots: Vec::new(),
14719 property_plot_settings: None,
14720 claim_mode: None,
14721 relocate_mode: None,
14722 sell_plot_confirm: None,
14723 sell_plot_armed_at: None,
14724 show_plant_menu: false,
14725 plant_menu_index: 0,
14726 show_farm_access: false,
14727 farm_access_name_draft: String::new(),
14728 farm_access_discount_bps: 0,
14729 farm_access_index: 0,
14730 plant_quantity: 1,
14731 in_combat: false,
14732 auto_attack: true,
14733 combat_has_los: false,
14734 attack_cd_ticks: 0,
14735 gcd_ticks: 0,
14736 weapon_ability_id: "unarmed".into(),
14737 mainhand_template_id: None,
14738 mainhand_label: None,
14739 offhand_template_id: None,
14740 offhand_label: None,
14741 mainhand_hand_slots: 1,
14742 defense: None,
14743 worn: BTreeMap::new(),
14744 carry_mass: 0.0,
14745 carry_mass_max: 0.0,
14746 encumbrance: flatland_protocol::EncumbranceState::Light,
14747 inventory_stacks: Vec::new(),
14748 keychain_stacks: Vec::new(),
14749 whisper_pouch_stacks: Vec::new(),
14750 combat_target_detail: None,
14751 statuses: Vec::new(),
14752 cast_progress: None,
14753 timed_channel: None,
14754 ability_cooldowns: Vec::new(),
14755 blocking_active: false,
14756 max_target_slots: 1,
14757 combat_slots: Vec::new(),
14758 rotation_presets: Vec::new(),
14759 known_abilities: Vec::new(),
14760 ability_meta: std::collections::HashMap::new(),
14761 hotbar: vec![None; 9],
14762 max_abilities_per_rotation: 0,
14763 show_loadout_menu: false,
14764 show_keychain_menu: false,
14765 keychain_menu_index: 0,
14766 show_rotation_editor: false,
14767 loadout_menu_index: 0,
14768 loadout_hotbar_slot: 1,
14769 loadout_ability_index: 0,
14770 loadout_focus_presets: false,
14771 rotation_editor: RotationEditorState::default(),
14772 harvest_in_progress: false,
14773 harvest_started_at: None,
14774 pending_craft_ack: None,
14775 pending_worker_job_ack: None,
14776 attending_worker_instance_id: None,
14777 quest_log: Vec::new(),
14778 interactables: Vec::new(),
14779 ledger: None,
14780 career: None,
14781 character_sheet_tab: CharacterSheetTab::Character,
14782 ledger_period: LedgerPeriod::Day,
14783 show_quest_offer: false,
14784 pending_quest_offer: None,
14785 show_quest_menu: false,
14786 quest_menu_index: 0,
14787 quest_withdraw_confirm: false,
14788 hired_workers: Vec::new(),
14789 show_workers_menu: false,
14790 workers_menu_index: 0,
14791 workers_menu_compact: false,
14792 worker_step_display: BTreeMap::new(),
14793 worker_error_display: BTreeMap::new(),
14794 show_worker_give_picker: false,
14795 worker_give_picker_index: 0,
14796 worker_give_picker: None,
14797 show_worker_give_target_picker: false,
14798 worker_give_target_picker_index: 0,
14799 worker_give_target_picker: None,
14800 show_worker_take_picker: false,
14801 worker_take_picker_index: 0,
14802 worker_take_picker: None,
14803 show_worker_teach_picker: false,
14804 worker_teach_picker_index: 0,
14805 worker_teach_picker: None,
14806 worker_route_editor: None,
14807 progression_curve: None,
14808 };
14809 state.player = state.entities.first().cloned();
14810 assert_eq!(
14811 state.nearest_interact_target().as_deref(),
14812 Some("ada_broker")
14813 );
14814 }
14815
14816 #[test]
14817 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
14818 let mut state = sample_state();
14819 state.placed_containers = vec![
14822 flatland_protocol::PlacedContainerView {
14823 id: "near".into(),
14824 template_id: "wooden_chest_small".into(),
14825 display_name: "Wooden Chest".into(),
14826 x: 130.0,
14827 y: 128.0,
14828 z: 0.0,
14829 locked: true,
14830 accessible: true,
14831 owner_character_id: None,
14832 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
14833 lock_id: None,
14834 capacity_volume: None,
14835 item_instance_id: Some(uuid::Uuid::from_u128(1)),
14836 tile_id: None,
14837 worker_lodging_capacity: None,
14838 blocking: false,
14839 blocking_radius_m: 0.0,
14840 },
14841 flatland_protocol::PlacedContainerView {
14842 id: "far".into(),
14843 template_id: "wooden_chest_small".into(),
14844 display_name: "Distant Chest".into(),
14845 x: 128.0 + CONTAINER_RANGE_M + 5.0,
14846 y: 128.0,
14847 z: 0.0,
14848 locked: false,
14849 accessible: true,
14850 owner_character_id: None,
14851 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
14852 lock_id: None,
14853 capacity_volume: None,
14854 item_instance_id: Some(uuid::Uuid::from_u128(2)),
14855 tile_id: None,
14856 worker_lodging_capacity: None,
14857 blocking: false,
14858 blocking_radius_m: 0.0,
14859 },
14860 ];
14861
14862 let nearby = state.nearby_containers();
14863 assert_eq!(
14864 nearby.len(),
14865 1,
14866 "far chest must not appear once out of range"
14867 );
14868 assert_eq!(nearby[0].view.id, "near");
14869 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
14870 assert!(nearby[0].rows[0].is_chest_shell);
14871
14872 state.placed_containers[0].accessible = false;
14875 let nearby = state.nearby_containers();
14876 assert_eq!(nearby.len(), 1);
14877 assert_eq!(nearby[0].rows.len(), 1);
14878 assert!(nearby[0].rows[0].is_chest_shell);
14879 }
14880
14881 #[test]
14882 fn chest_pickup_destinations_offer_person_and_worn_bag() {
14883 let mut state = sample_state();
14884 let back_id = uuid::Uuid::from_u128(42);
14885 state.worn.insert(
14886 BodySlot::Back,
14887 flatland_protocol::ItemStack {
14888 template_id: "travel_backpack".into(),
14889 quantity: 1,
14890 item_instance_id: Some(back_id),
14891 props: Default::default(),
14892 status_bindings: Vec::new(),
14893 contents: Vec::new(),
14894 display_name: Some("Travel Backpack".into()),
14895 category: Some("container".into()),
14896 base_mass: Some(2.5),
14897 base_volume: Some(12.0),
14898 capacity_volume: Some(80.0),
14899 stackable: Some(false),
14900 world_placeable: Some(false),
14901 worker_lodging_capacity: None,
14902 equip_slot: None,
14903 armor_physical: None,
14904 resists: vec![],
14905 hand_slots: None,
14906 listable: None,
14907 },
14908 );
14909 let opts = state.chest_pickup_destinations("chest-1");
14910 assert!(matches!(
14911 opts.first().map(|o| &o.kind),
14912 Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
14913 ));
14914 assert!(opts.iter().any(|o| matches!(
14915 &o.kind,
14916 MoveOptionKind::PickupPlaced {
14917 nest_parent_instance_id: None,
14918 ..
14919 }
14920 )));
14921 assert!(opts.iter().any(|o| matches!(
14922 &o.kind,
14923 MoveOptionKind::PickupPlaced {
14924 nest_parent_instance_id: Some(id),
14925 ..
14926 } if *id == back_id
14927 )));
14928 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
14929 }
14930
14931 #[test]
14932 fn placed_container_public_label_hides_owner_custom_name() {
14933 let owner = uuid::Uuid::from_u128(99);
14934 let mut state = sample_state();
14935 state.character_id = Some(uuid::Uuid::from_u128(1));
14936 state.inventory_hints.insert(
14937 "wooden_chest_medium".into(),
14938 InventoryHint {
14939 display_name: "Medium Wooden Chest".into(),
14940 category: "container".into(),
14941 base_mass: None,
14942 base_volume: None,
14943 capacity_volume: None,
14944 stackable: false,
14945 listable: true,
14946 },
14947 );
14948 let chest = flatland_protocol::PlacedContainerView {
14949 id: "c1".into(),
14950 template_id: "wooden_chest_medium".into(),
14951 display_name: "Barry's Loot #a3f2".into(),
14952 x: 128.0,
14953 y: 128.0,
14954 z: 0.0,
14955 locked: false,
14956 accessible: true,
14957 owner_character_id: Some(owner),
14958 contents: vec![],
14959 lock_id: None,
14960 capacity_volume: None,
14961 item_instance_id: None,
14962 tile_id: None,
14963 worker_lodging_capacity: None,
14964 blocking: false,
14965 blocking_radius_m: 0.0,
14966 };
14967 assert_eq!(
14968 state.placed_container_public_label(&chest),
14969 "Medium Wooden Chest"
14970 );
14971 state.character_id = Some(owner);
14972 assert_eq!(
14973 state.placed_container_public_label(&chest),
14974 "Barry's Loot #a3f2"
14975 );
14976 }
14977
14978 #[test]
14979 fn location_context_shows_crop_growth_percent_not_depleted() {
14980 let mut state = sample_state();
14981 state.player = state.entities.first().cloned();
14982 state.resource_nodes[0].label = "Carrot (growing)".into();
14983 state.resource_nodes[0].x = 128.2;
14984 state.resource_nodes[0].y = 128.0;
14985 state.resource_nodes[0].state = ResourceNodeState::Cooldown;
14986 state.resource_nodes[0].growth_progress = Some(0.47);
14987 let lines = state.location_context_lines();
14988 let line = lines
14989 .iter()
14990 .find(|l| l.text.contains("Carrot"))
14991 .map(|l| l.text.as_str())
14992 .unwrap_or("");
14993 assert!(
14994 line.contains("(growing, 47%)"),
14995 "expected growth percent, got: {line}"
14996 );
14997 assert!(
14998 !line.contains("depleted"),
14999 "growing crop should not show depleted: {line}"
15000 );
15001 }
15002
15003 #[test]
15004 fn resource_node_near_action_suffix_prefers_growth() {
15005 let node = ResourceNodeView {
15006 id: "crop".into(),
15007 label: "Wheat".into(),
15008 x: 0.0,
15009 y: 0.0,
15010 z: 0.0,
15011 item_template: "wheat".into(),
15012 state: ResourceNodeState::Cooldown,
15013 blocking: false,
15014 blocking_radius_m: 0.0,
15015 tile_id: None,
15016 yaw: 0.0,
15017 pitch: 0.0,
15018 roll: 0.0,
15019 draw_scale: 1.0,
15020 sprite_mode: None,
15021 growth_progress: Some(0.12),
15022 presentation_state: None,
15023 channel_start_tick: None,
15024 channel_end_tick: None,
15025 harvest_drop_templates: vec![],
15026 };
15027 assert_eq!(
15028 resource_node_near_action_suffix(&node),
15029 " (growing, 12%)"
15030 );
15031 }
15032
15033 #[test]
15034 fn location_context_lists_nearby_resource_node() {
15035 let mut state = sample_state();
15036 state.player = state.entities.first().cloned();
15037 state.resource_nodes[0].x = 128.2;
15038 state.resource_nodes[0].y = 128.0;
15039 let lines = state.location_context_lines();
15040 assert!(
15041 lines
15042 .iter()
15043 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
15044 "expected resource node in context: {:?}",
15045 lines
15046 );
15047 }
15048
15049 #[test]
15050 fn quest_board_usable_within_board_radius() {
15051 let mut state = sample_state();
15052 state.player = state.entities.first().cloned();
15053 state.interactables = vec![flatland_protocol::InteractableView {
15054 id: "board-1".into(),
15055 kind: "quest_board".into(),
15056 label: "Town Quest Board".into(),
15057 x: 130.5,
15058 y: 128.0,
15059 z: 0.0,
15060 board_id: Some("starter_town_board".into()),
15061 }];
15062 assert_eq!(
15064 state.nearest_interact_target().as_deref(),
15065 Some("board-1"),
15066 "quest board should be selectable at ~2.5m"
15067 );
15068 let lines = state.location_context_lines();
15069 assert!(
15070 lines
15071 .iter()
15072 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
15073 "HUD should advertise f when board is in range: {:?}",
15074 lines
15075 );
15076 }
15077
15078 #[test]
15079 fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
15080 let mut state = sample_state();
15081 state.worn.insert(
15082 BodySlot::Back,
15083 flatland_protocol::ItemStack {
15084 template_id: "travel_backpack".into(),
15085 quantity: 1,
15086 item_instance_id: Some(uuid::Uuid::from_u128(3)),
15087 props: Default::default(),
15088 status_bindings: Vec::new(),
15089 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
15090 display_name: None,
15091 category: None,
15092 base_mass: None,
15093 base_volume: None,
15094 capacity_volume: None,
15095 stackable: None,
15096 world_placeable: None,
15097 worker_lodging_capacity: None,
15098 equip_slot: None,
15099 armor_physical: None,
15100 resists: vec![],
15101 hand_slots: None,
15102 listable: None,
15103 },
15104 );
15105 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
15106 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15107 id: "chest-1".into(),
15108 template_id: "wooden_chest_small".into(),
15109 display_name: "Wooden Chest".into(),
15110 x: 129.0,
15111 y: 128.0,
15112 z: 0.0,
15113 locked: false,
15114 accessible: true,
15115 owner_character_id: None,
15116 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
15117 lock_id: None,
15118 capacity_volume: None,
15119 item_instance_id: Some(uuid::Uuid::from_u128(4)),
15120 tile_id: None,
15121 worker_lodging_capacity: None,
15122 blocking: false,
15123 blocking_radius_m: 0.0,
15124 }];
15125
15126 state.inventory_tab = InventoryTab::OnPerson;
15127 let rows = state.inventory_selectable_rows();
15128 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
15129 assert_eq!(
15130 sections,
15131 vec![
15132 InventorySection::Worn, InventorySection::Worn, InventorySection::Person, ]
15136 );
15137 assert_eq!(rows[0].stack.template_id, "travel_backpack");
15138 assert!(rows[0].is_equip_shell);
15139 assert_eq!(rows[1].stack.template_id, "iron_ore");
15140 assert_eq!(rows[1].depth, 1);
15141 assert_eq!(rows[2].stack.template_id, "lumber");
15142
15143 let lines = state.inventory_browser_lines();
15144 assert!(lines.iter().any(|l| matches!(
15145 l,
15146 InventoryBrowserLine::Section(s) if s.contains("Worn")
15147 )));
15148 assert!(lines.iter().any(|l| matches!(
15149 l,
15150 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
15151 || text.contains("backpack")
15152 )));
15153 assert!(!lines.iter().any(|l| matches!(
15154 l,
15155 InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
15156 )));
15157
15158 state.inventory_tab = InventoryTab::Nearby;
15159 let nearby_rows = state.inventory_selectable_rows();
15160 assert_eq!(nearby_rows.len(), 2);
15161 assert!(nearby_rows[0].is_chest_shell);
15162 assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
15163 let nearby_lines = state.inventory_browser_lines();
15164 assert!(nearby_lines.iter().any(|l| matches!(
15165 l,
15166 InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
15167 )));
15168 }
15169
15170 #[test]
15171 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
15172 let mut state = sample_state();
15173 let back_id = uuid::Uuid::from_u128(5);
15174 state.worn.insert(
15175 BodySlot::Back,
15176 flatland_protocol::ItemStack {
15177 template_id: "travel_backpack".into(),
15178 quantity: 1,
15179 item_instance_id: Some(back_id),
15180 props: Default::default(),
15181 status_bindings: Vec::new(),
15182 contents: Vec::new(),
15183 display_name: None,
15184 category: Some("container".into()),
15185 base_mass: None,
15186 base_volume: None,
15187 capacity_volume: Some(80.0),
15188 stackable: None,
15189 world_placeable: None,
15190 worker_lodging_capacity: None,
15191 equip_slot: None,
15192 armor_physical: None,
15193 resists: vec![],
15194 hand_slots: None,
15195 listable: None,
15196 },
15197 );
15198 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15199 id: "chest-1".into(),
15200 template_id: "wooden_chest_small".into(),
15201 display_name: "Wooden Chest".into(),
15202 x: 129.0,
15203 y: 128.0,
15204 z: 0.0,
15205 locked: false,
15206 accessible: true,
15207 owner_character_id: None,
15208 contents: Vec::new(),
15209 lock_id: None,
15210 capacity_volume: None,
15211 item_instance_id: Some(uuid::Uuid::from_u128(6)),
15212 tile_id: None,
15213 worker_lodging_capacity: None,
15214 blocking: false,
15215 blocking_radius_m: 0.0,
15216 }];
15217
15218 let opts = state.move_destinations_for(
15221 &flatland_protocol::InventoryLocation::Root,
15222 None,
15223 None,
15224 "lumber",
15225 );
15226 assert!(!opts.iter().any(|o| matches!(
15227 &o.kind,
15228 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
15229 )));
15230 assert!(opts.iter().any(|o| matches!(
15231 &o.kind,
15232 MoveOptionKind::Move { location, parent_instance_id, .. }
15233 if *location == flatland_protocol::InventoryLocation::Worn {
15234 slot: BodySlot::Back,
15235 } && *parent_instance_id == Some(back_id)
15236 )));
15237 assert!(opts.iter().any(|o| matches!(
15238 &o.kind,
15239 MoveOptionKind::Move { location, .. }
15240 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
15241 )));
15242 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
15243 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
15244
15245 let from_backpack = flatland_protocol::InventoryLocation::Worn {
15249 slot: BodySlot::Back,
15250 };
15251 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
15252 assert!(!opts.iter().any(|o| matches!(
15253 &o.kind,
15254 MoveOptionKind::Move { location, parent_instance_id, .. }
15255 if *location == from_backpack && *parent_instance_id == Some(back_id)
15256 )));
15257 assert!(opts.iter().any(|o| matches!(
15258 &o.kind,
15259 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
15260 )));
15261 }
15262
15263 #[test]
15264 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
15265 let mut state = sample_state();
15266 state.worn.insert(
15269 BodySlot::Waist,
15270 flatland_protocol::ItemStack {
15271 template_id: "simple_belt".into(),
15272 quantity: 1,
15273 item_instance_id: Some(uuid::Uuid::from_u128(10)),
15274 props: Default::default(),
15275 status_bindings: Vec::new(),
15276 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
15277 display_name: None,
15278 category: Some("container".into()),
15279 base_mass: None,
15280 base_volume: None,
15281 capacity_volume: None,
15282 stackable: None,
15283 world_placeable: None,
15284 worker_lodging_capacity: None,
15285 equip_slot: None,
15286 armor_physical: None,
15287 resists: vec![],
15288 hand_slots: None,
15289 listable: None,
15290 },
15291 );
15292 state.worn.insert(
15293 BodySlot::Head,
15294 flatland_protocol::ItemStack {
15295 template_id: "cloth_cap".into(),
15296 quantity: 1,
15297 item_instance_id: Some(uuid::Uuid::from_u128(11)),
15298 props: Default::default(),
15299 status_bindings: Vec::new(),
15300 contents: Vec::new(),
15301 display_name: None,
15302 category: Some("armor".into()),
15303 base_mass: None,
15304 base_volume: None,
15305 capacity_volume: None,
15306 stackable: None,
15307 world_placeable: None,
15308 worker_lodging_capacity: None,
15309 equip_slot: None,
15310 armor_physical: None,
15311 resists: vec![],
15312 hand_slots: None,
15313 listable: None,
15314 },
15315 );
15316 state.worn.insert(
15317 BodySlot::Back,
15318 flatland_protocol::ItemStack {
15319 template_id: "travel_backpack".into(),
15320 quantity: 1,
15321 item_instance_id: Some(uuid::Uuid::from_u128(12)),
15322 props: Default::default(),
15323 status_bindings: Vec::new(),
15324 contents: Vec::new(),
15325 display_name: None,
15326 category: Some("container".into()),
15327 base_mass: None,
15328 base_volume: None,
15329 capacity_volume: None,
15330 stackable: None,
15331 world_placeable: None,
15332 worker_lodging_capacity: None,
15333 equip_slot: None,
15334 armor_physical: None,
15335 resists: vec![],
15336 hand_slots: None,
15337 listable: None,
15338 },
15339 );
15340
15341 let rows = state.worn_rows();
15342 assert_eq!(rows.len(), 4);
15344 assert_eq!(rows[0].stack.template_id, "cloth_cap");
15345 assert!(rows[0].is_equip_shell);
15346 assert_eq!(rows[1].stack.template_id, "travel_backpack");
15347 assert!(rows[1].is_equip_shell);
15348 assert_eq!(rows[2].stack.template_id, "simple_belt");
15349 assert!(rows[2].is_equip_shell);
15350 assert_eq!(rows[3].stack.template_id, "leather_pouch");
15351 assert_eq!(rows[3].depth, 1);
15352 assert!(!rows[3].is_equip_shell);
15353 }
15354
15355 #[test]
15356 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
15357 let mut state = sample_state();
15358 state.worn.insert(
15359 BodySlot::Waist,
15360 flatland_protocol::ItemStack {
15361 template_id: "simple_belt".into(),
15362 quantity: 1,
15363 item_instance_id: Some(uuid::Uuid::from_u128(20)),
15364 props: Default::default(),
15365 status_bindings: Vec::new(),
15366 contents: Vec::new(),
15367 display_name: Some("Simple Belt".into()),
15368 category: Some("container".into()),
15369 base_mass: None,
15370 base_volume: None,
15371 capacity_volume: None,
15372 stackable: None,
15373 world_placeable: None,
15374 worker_lodging_capacity: None,
15375 equip_slot: None,
15376 armor_physical: None,
15377 resists: vec![],
15378 hand_slots: None,
15379 listable: None,
15380 },
15381 );
15382 state.worn.insert(
15383 BodySlot::Head,
15384 flatland_protocol::ItemStack {
15385 template_id: "cloth_cap".into(),
15386 quantity: 1,
15387 item_instance_id: Some(uuid::Uuid::from_u128(21)),
15388 props: Default::default(),
15389 status_bindings: Vec::new(),
15390 contents: Vec::new(),
15391 display_name: Some("Cloth Cap".into()),
15392 category: Some("armor".into()),
15393 base_mass: None,
15394 base_volume: None,
15395 capacity_volume: None,
15396 stackable: None,
15397 world_placeable: None,
15398 worker_lodging_capacity: None,
15399 equip_slot: None,
15400 armor_physical: None,
15401 resists: vec![],
15402 hand_slots: None,
15403 listable: None,
15404 },
15405 );
15406
15407 let opts = state.move_destinations_for(
15408 &flatland_protocol::InventoryLocation::Root,
15409 None,
15410 None,
15411 "leather_pouch",
15412 );
15413 assert!(
15414 opts.iter().any(|o| matches!(
15415 &o.kind,
15416 MoveOptionKind::Move { location, .. }
15417 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
15418 )),
15419 "belt loop must be offered when moving a pouch"
15420 );
15421 assert!(
15422 !opts.iter().any(|o| matches!(
15423 &o.kind,
15424 MoveOptionKind::Move { location, .. }
15425 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
15426 )),
15427 "armor slots can't hold other items and must not appear as move destinations"
15428 );
15429 let belt_opt = opts
15430 .iter()
15431 .find(|o| matches!(
15432 &o.kind,
15433 MoveOptionKind::Move { location, .. }
15434 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
15435 ))
15436 .unwrap();
15437 assert!(belt_opt.label.contains("belt loop"));
15438
15439 let opts = state.move_destinations_for(
15440 &flatland_protocol::InventoryLocation::Root,
15441 None,
15442 None,
15443 "lumber",
15444 );
15445 assert!(
15446 !opts.iter().any(|o| o.label.contains("belt loop")),
15447 "loose materials must not target the belt shell — only nested pouches"
15448 );
15449 }
15450
15451 #[test]
15452 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
15453 let mut state = sample_state();
15454 let belt_id = uuid::Uuid::from_u128(30);
15455 let pouch_id = uuid::Uuid::from_u128(31);
15456 state.worn.insert(
15457 BodySlot::Waist,
15458 flatland_protocol::ItemStack {
15459 template_id: "simple_belt".into(),
15460 quantity: 1,
15461 item_instance_id: Some(belt_id),
15462 props: Default::default(),
15463 status_bindings: Vec::new(),
15464 world_placeable: None,
15465 worker_lodging_capacity: None,
15466 equip_slot: None,
15467 armor_physical: None,
15468 resists: vec![],
15469 hand_slots: None,
15470 contents: vec![flatland_protocol::ItemStack {
15471 template_id: "dimensional_pouch".into(),
15472 quantity: 1,
15473 item_instance_id: Some(pouch_id),
15474 props: Default::default(),
15475 status_bindings: Vec::new(),
15476 contents: Vec::new(),
15477 display_name: Some("Dimensional Pouch".into()),
15478 category: Some("container".into()),
15479 base_mass: None,
15480 base_volume: None,
15481 capacity_volume: Some(200.0),
15482 stackable: None,
15483 world_placeable: None,
15484 worker_lodging_capacity: None,
15485 equip_slot: None,
15486 armor_physical: None,
15487 resists: vec![],
15488 hand_slots: None,
15489 listable: None,
15490 }],
15491 display_name: Some("Simple Belt".into()),
15492 category: Some("container".into()),
15493 base_mass: None,
15494 base_volume: None,
15495 capacity_volume: None,
15496 stackable: None,
15497 listable: None,
15498 },
15499 );
15500
15501 let opts = state.move_destinations_for(
15502 &flatland_protocol::InventoryLocation::Root,
15503 None,
15504 None,
15505 "iron_ore",
15506 );
15507 assert!(
15508 opts.iter().any(|o| matches!(
15509 &o.kind,
15510 MoveOptionKind::Move {
15511 location,
15512 parent_instance_id,
15513 ..
15514 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
15515 && *parent_instance_id == Some(pouch_id)
15516 )),
15517 "dimensional pouch clipped on belt must accept loose items"
15518 );
15519 assert!(
15520 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
15521 "destination label should name the pouch"
15522 );
15523 }
15524
15525 #[test]
15526 fn container_volume_label_on_placed_chest_shell() {
15527 let mut state = sample_state();
15528 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15529 id: "chest-1".into(),
15530 template_id: "wooden_chest_small".into(),
15531 display_name: "Camp Chest".into(),
15532 x: 129.0,
15533 y: 128.0,
15534 z: 0.0,
15535 locked: false,
15536 accessible: true,
15537 owner_character_id: None,
15538 contents: vec![flatland_protocol::ItemStack {
15539 template_id: "iron_ore".into(),
15540 quantity: 2,
15541 item_instance_id: None,
15542 props: Default::default(),
15543 status_bindings: Vec::new(),
15544 contents: Vec::new(),
15545 display_name: None,
15546 category: None,
15547 base_mass: None,
15548 base_volume: Some(2.0),
15549 capacity_volume: None,
15550 stackable: None,
15551 world_placeable: None,
15552 worker_lodging_capacity: None,
15553 equip_slot: None,
15554 armor_physical: None,
15555 resists: vec![],
15556 hand_slots: None,
15557 listable: None,
15558 }],
15559 lock_id: None,
15560 capacity_volume: Some(60.0),
15561 item_instance_id: Some(uuid::Uuid::from_u128(4)),
15562 tile_id: None,
15563 worker_lodging_capacity: None,
15564 blocking: false,
15565 blocking_radius_m: 0.0,
15566 }];
15567 let nearby = state.nearby_containers();
15568 let label = state.container_volume_label(&nearby[0].rows[0]);
15569 assert!(
15570 label.contains("vol 4/60"),
15571 "expected used/cap in label, got {label}"
15572 );
15573 assert!(
15574 label.contains("56 free"),
15575 "expected free space, got {label}"
15576 );
15577 }
15578
15579 #[test]
15580 fn key_pair_chest_label_from_placed_lock_id() {
15581 let mut state = sample_state();
15582 let owner = uuid::Uuid::from_u128(77);
15583 state.character_id = Some(owner);
15584 let lock = uuid::Uuid::from_u128(99).to_string();
15585 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15586 id: "chest-1".into(),
15587 template_id: "wooden_chest_small".into(),
15588 display_name: "Barry's Loot #a3f2".into(),
15589 x: 129.0,
15590 y: 128.0,
15591 z: 0.0,
15592 locked: true,
15593 accessible: true,
15594 owner_character_id: Some(owner),
15595 contents: Vec::new(),
15596 lock_id: Some(lock.clone()),
15597 capacity_volume: None,
15598 item_instance_id: Some(uuid::Uuid::from_u128(4)),
15599 tile_id: None,
15600 worker_lodging_capacity: None,
15601 blocking: false,
15602 blocking_radius_m: 0.0,
15603 }];
15604 let key_id = uuid::Uuid::from_u128(5);
15605 let key = flatland_protocol::ItemStack {
15606 template_id: KEY_TEMPLATE.into(),
15607 quantity: 1,
15608 item_instance_id: Some(key_id),
15609 props: BTreeMap::from([
15610 (PROP_OPENS_LOCK_ID.into(), lock),
15611 (
15612 PROP_OPENS_CONTAINER_NAME.into(),
15613 "Barry's Loot #a3f2".into(),
15614 ),
15615 ]),
15616 status_bindings: Vec::new(),
15617 contents: Vec::new(),
15618 display_name: Some("Container Key".into()),
15619 category: Some("key".into()),
15620 base_mass: None,
15621 base_volume: None,
15622 capacity_volume: None,
15623 stackable: None,
15624 world_placeable: None,
15625 worker_lodging_capacity: None,
15626 equip_slot: None,
15627 armor_physical: None,
15628 resists: vec![],
15629 hand_slots: None,
15630 listable: None,
15631 };
15632 state.inventory_stacks = vec![key.clone()];
15633 assert_eq!(
15634 state.key_pair_chest_label(&key).as_deref(),
15635 Some("Barry's Loot #a3f2")
15636 );
15637 assert!(state.key_drop_blocked(&key));
15638 }
15639
15640 #[test]
15641 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
15642 let mut state = sample_state();
15643 let lock = uuid::Uuid::from_u128(101).to_string();
15644 let key = flatland_protocol::ItemStack {
15645 template_id: KEY_TEMPLATE.into(),
15646 quantity: 1,
15647 item_instance_id: Some(uuid::Uuid::from_u128(7)),
15648 props: BTreeMap::from([
15649 (PROP_OPENS_LOCK_ID.into(), lock),
15650 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
15651 ]),
15652 status_bindings: Vec::new(),
15653 contents: Vec::new(),
15654 display_name: None,
15655 category: Some("key".into()),
15656 base_mass: None,
15657 base_volume: None,
15658 capacity_volume: None,
15659 stackable: None,
15660 world_placeable: None,
15661 worker_lodging_capacity: None,
15662 equip_slot: None,
15663 armor_physical: None,
15664 resists: vec![],
15665 hand_slots: None,
15666 listable: None,
15667 };
15668 state.placed_containers.clear();
15669 assert_eq!(
15670 state.key_pair_chest_label(&key).as_deref(),
15671 Some("Camp Stash")
15672 );
15673 }
15674
15675 #[test]
15676 fn key_drop_allowed_when_paired_chest_unlocked() {
15677 let mut state = sample_state();
15678 let lock = uuid::Uuid::from_u128(100).to_string();
15679 let key_id = uuid::Uuid::from_u128(6);
15680 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15681 id: "chest-1".into(),
15682 template_id: "wooden_chest_small".into(),
15683 display_name: "Camp Chest".into(),
15684 x: 129.0,
15685 y: 128.0,
15686 z: 0.0,
15687 locked: false,
15688 accessible: true,
15689 owner_character_id: None,
15690 contents: Vec::new(),
15691 lock_id: Some(lock.clone()),
15692 capacity_volume: None,
15693 item_instance_id: None,
15694 tile_id: None,
15695 worker_lodging_capacity: None,
15696 blocking: false,
15697 blocking_radius_m: 0.0,
15698 }];
15699 let key = flatland_protocol::ItemStack {
15700 template_id: KEY_TEMPLATE.into(),
15701 quantity: 1,
15702 item_instance_id: Some(key_id),
15703 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
15704 status_bindings: Vec::new(),
15705 contents: Vec::new(),
15706 display_name: None,
15707 category: Some("key".into()),
15708 base_mass: None,
15709 base_volume: None,
15710 capacity_volume: None,
15711 stackable: None,
15712 world_placeable: None,
15713 worker_lodging_capacity: None,
15714 equip_slot: None,
15715 armor_physical: None,
15716 resists: vec![],
15717 hand_slots: None,
15718 listable: None,
15719 };
15720 state.inventory_stacks = vec![key.clone()];
15721 assert!(!state.key_drop_blocked(&key));
15722 let opts = state.move_destinations_for(
15723 &flatland_protocol::InventoryLocation::Root,
15724 None,
15725 Some(key_id),
15726 KEY_TEMPLATE,
15727 );
15728 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
15729 }
15730
15731 #[test]
15732 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
15733 use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
15734
15735 let mut state = sample_state();
15736 let curve = ProgressionCurve::default();
15737 let bootstrap = ProgressionXp::bootstrap_new(
15738 curve.baseline_display,
15739 curve.xp_base,
15740 curve.xp_growth,
15741 );
15742 let mut fresh = bootstrap.clone();
15743 fresh.strength += 0.08;
15744 if let Some(player) = state.player.as_mut() {
15745 player.progression_xp = Some(bootstrap);
15746 }
15747
15748 let combat = CombatHud {
15749 progression_xp: Some(fresh.clone()),
15750 progression_baseline: curve.baseline_display,
15751 progression_xp_base: curve.xp_base,
15752 progression_xp_growth: curve.xp_growth,
15753 attributes: state.player.as_ref().and_then(|p| p.attributes),
15754 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
15755 ..CombatHud::default()
15756 };
15757 state.apply_combat_hud(&combat);
15758
15759 let xp = state
15760 .player
15761 .as_ref()
15762 .and_then(|p| p.progression_xp.as_ref())
15763 .expect("xp");
15764 assert!((xp.strength - fresh.strength).abs() < 0.001);
15765 assert!(state.progression_curve.is_some());
15766 }
15767
15768 #[test]
15769 fn combat_hud_syncs_known_abilities_and_hotbar() {
15770 use flatland_protocol::CombatHud;
15771
15772 let mut state = sample_state();
15773 let combat = CombatHud {
15774 known_abilities: vec!["unarmed".into(), "fireball".into()],
15775 hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
15776 max_abilities_per_rotation: 4,
15777 ability_id: "short_sword_slash".into(),
15778 ..CombatHud::default()
15779 };
15780 state.apply_combat_hud(&combat);
15781
15782 assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
15783 assert_eq!(state.hotbar_ability(1), Some("fireball"));
15784 assert_eq!(state.hotbar_ability(2), None);
15785 assert_eq!(state.hotbar_ability(3), Some("unarmed"));
15786 assert_eq!(state.max_abilities_per_rotation, 4);
15787 let choices = state.loadout_ability_choices();
15788 assert!(choices.iter().any(|a| a == "short_sword_slash"));
15789 assert!(choices.iter().any(|a| a == "fireball"));
15790 }
15791
15792 #[test]
15793 fn loadout_hotbar_choices_include_inventory_consumables() {
15794 let mut state = sample_state();
15795 state.known_abilities = vec!["unarmed".into()];
15796 state.weapon_ability_id = "unarmed".into();
15797 state.inventory_stacks = vec![flatland_protocol::ItemStack {
15798 template_id: "bottle_of_water".into(),
15799 quantity: 3,
15800 item_instance_id: Some(uuid::Uuid::from_u128(9)),
15801 display_name: Some("Bottle of Water".into()),
15802 category: Some("consumable".into()),
15803 ..Default::default()
15804 }];
15805 state.inventory.insert("bottle_of_water".into(), 3);
15806 state.inventory_hints.insert(
15807 "bottle_of_water".into(),
15808 InventoryHint {
15809 display_name: "Bottle of Water".into(),
15810 category: "consumable".into(),
15811 ..Default::default()
15812 },
15813 );
15814
15815 let choices = state.loadout_hotbar_choices();
15816 assert!(choices.iter().any(|c| c.binding == "unarmed"));
15817 let water = choices
15818 .iter()
15819 .find(|c| c.binding == "item:bottle_of_water")
15820 .expect("water binding");
15821 assert_eq!(water.meta.as_deref(), Some("use"));
15822 assert!(water.label.contains("Water"));
15823 assert_eq!(
15824 state.hotbar_slot_label(1),
15825 None,
15826 "unbound until set"
15827 );
15828 state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
15829 assert_eq!(
15830 state.hotbar_slot_label(5).as_deref(),
15831 Some("Bottle of Water×3")
15832 );
15833 }
15834
15835 #[test]
15836 fn loose_consumable_move_picker_offers_use_and_storage() {
15837 let mut state = sample_state();
15838 let inst = uuid::Uuid::from_u128(77);
15839 state.inventory_stacks = vec![flatland_protocol::ItemStack {
15840 template_id: "carrot".into(),
15841 quantity: 2,
15842 item_instance_id: Some(inst),
15843 props: Default::default(),
15844 status_bindings: Vec::new(),
15845 contents: Vec::new(),
15846 display_name: Some("Wild Carrot".into()),
15847 category: Some("consumable".into()),
15848 base_mass: None,
15849 base_volume: None,
15850 capacity_volume: None,
15851 stackable: Some(true),
15852 world_placeable: None,
15853 worker_lodging_capacity: None,
15854 equip_slot: None,
15855 armor_physical: None,
15856 resists: vec![],
15857 hand_slots: None,
15858 listable: None,
15859 }];
15860 state.inventory_hints.insert(
15861 "carrot".into(),
15862 InventoryHint {
15863 display_name: "Wild Carrot".into(),
15864 category: "consumable".into(),
15865 base_mass: Some(0.15),
15866 base_volume: Some(0.3),
15867 capacity_volume: None,
15868 stackable: true,
15869 listable: true,
15870 },
15871 );
15872 state.show_inventory_menu = true;
15873 state.inventory_menu_index = 0;
15874
15875 let row = state.inventory_selected_row().expect("carrot row");
15876 let mut options = state.move_destinations_for(
15877 &row.from,
15878 row.from_parent_instance_id,
15879 row.stack.item_instance_id,
15880 &row.stack.template_id,
15881 );
15882 if row.from == flatland_protocol::InventoryLocation::Root
15883 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
15884 {
15885 options.insert(
15886 0,
15887 MoveOption {
15888 label: "Use (eat / drink)".into(),
15889 kind: MoveOptionKind::Use,
15890 },
15891 );
15892 }
15893
15894 assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
15895 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
15896 assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
15897 }
15898
15899 #[test]
15900 fn inventory_category_group_order_is_stable() {
15901 assert_eq!(inventory_category_group("weapon").0, "Weapons");
15902 assert_eq!(inventory_category_group("armor").0, "Armor");
15903 assert_eq!(inventory_category_group("consumable").0, "Consumables");
15904 assert_eq!(inventory_category_group("resource").0, "Resources");
15905 assert_eq!(inventory_category_group("container").0, "Containers");
15906 assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
15907 assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
15908 }
15909
15910 #[test]
15911 fn page_list_index_clamps_without_wrap() {
15912 assert_eq!(page_list_index(0, -1, 25), 0);
15913 assert_eq!(page_list_index(0, 1, 25), 10);
15914 assert_eq!(page_list_index(12, 1, 25), 22);
15915 assert_eq!(page_list_index(22, 1, 25), 24);
15916 assert_eq!(page_list_index(5, 1, 0), 0);
15917 assert_eq!(page_list_index(3, -1, 8), 0);
15918 }
15919
15920 #[test]
15921 fn inventory_filter_hides_non_matching_person_items() {
15922 let mut state = sample_state();
15923 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
15924 sword.display_name = Some("Iron Sword".into());
15925 sword.category = Some("weapon".into());
15926 let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
15927 herb.display_name = Some("Wild Herb".into());
15928 herb.category = Some("consumable".into());
15929 state.inventory_stacks = vec![sword, herb];
15930 state.inventory_tab = InventoryTab::OnPerson;
15931 state.inventory_filter = "sword".into();
15932
15933 let rows = state.inventory_selectable_rows();
15934 assert_eq!(rows.len(), 1);
15935 assert_eq!(rows[0].stack.template_id, "iron_sword");
15936
15937 let lines = state.inventory_browser_lines();
15938 assert!(lines.iter().any(|l| matches!(
15939 l,
15940 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
15941 )));
15942 assert!(!lines.iter().any(|l| matches!(
15943 l,
15944 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
15945 )));
15946 }
15947
15948 #[test]
15949 fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
15950 let mut state = sample_state();
15951 let id_a = uuid::Uuid::from_u128(0xa1);
15952 let id_b = uuid::Uuid::from_u128(0xb2);
15953 let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
15954 sword_a.display_name = Some("Iron Sword".into());
15955 sword_a.category = Some("weapon".into());
15956 sword_a.item_instance_id = Some(id_a);
15957 let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
15958 sword_b.display_name = Some("Iron Sword".into());
15959 sword_b.category = Some("weapon".into());
15960 sword_b.item_instance_id = Some(id_b);
15961 state.inventory_stacks = vec![sword_a, sword_b];
15962 state.inventory_tab = InventoryTab::OnPerson;
15963
15964 let lines = state.inventory_browser_lines();
15965 let items: Vec<_> = lines
15966 .iter()
15967 .filter_map(|l| match l {
15968 InventoryBrowserLine::Item {
15969 title,
15970 instance_tooltip,
15971 ..
15972 } => Some((title.clone(), instance_tooltip.clone())),
15973 _ => None,
15974 })
15975 .collect();
15976 assert_eq!(items.len(), 2);
15977 for (title, tip) in &items {
15978 assert!(
15979 !title.contains('#'),
15980 "title should not show instance suffix: {title}"
15981 );
15982 assert!(
15983 tip.is_some(),
15984 "two identical rows should expose instance on hover"
15985 );
15986 }
15987
15988 state.inventory_stacks.pop();
15989 let lines = state.inventory_browser_lines();
15990 let one = lines.iter().find_map(|l| match l {
15991 InventoryBrowserLine::Item {
15992 title,
15993 instance_tooltip,
15994 ..
15995 } => Some((title.clone(), instance_tooltip.clone())),
15996 _ => None,
15997 });
15998 let (title, tip) = one.expect("one sword row");
15999 assert!(!title.contains('#'));
16000 assert!(tip.is_none(), "single row should not need instance tooltip");
16001 }
16002
16003 #[test]
16004 fn inventory_person_rows_group_by_category() {
16005 let mut state = sample_state();
16006 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
16007 sword.category = Some("weapon".into());
16008 sword.display_name = Some("Iron Sword".into());
16009 let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
16010 ore.category = Some("resource".into());
16011 ore.display_name = Some("Iron Ore".into());
16012 let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
16013 potion.category = Some("consumable".into());
16014 potion.display_name = Some("Health Potion".into());
16015 state.inventory_stacks = vec![ore, potion, sword];
16016 state.inventory_tab = InventoryTab::OnPerson;
16017
16018 let lines = state.inventory_browser_lines();
16019 let labels: Vec<&str> = lines
16020 .iter()
16021 .filter_map(|l| match l {
16022 InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
16023 _ => None,
16024 })
16025 .collect();
16026 assert!(
16027 labels.iter().any(|s| s.contains("Weapons")),
16028 "expected Weapons group: {labels:?}"
16029 );
16030 assert!(labels.iter().any(|s| s.contains("Consumables")));
16031 assert!(labels.iter().any(|s| s.contains("Resources")));
16032
16033 let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
16034 let consumable_pos = labels.iter().position(|s| s.contains("Consumables")).unwrap();
16035 let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
16036 assert!(weapon_pos < consumable_pos);
16037 assert!(consumable_pos < resource_pos);
16038 }
16039
16040 #[test]
16041 fn inventory_tab_cycle_resets_selection() {
16042 let mut state = sample_state();
16043 state.inventory_tab = InventoryTab::OnPerson;
16044 state.inventory_menu_index = 3;
16045 state.inventory_tab = state.inventory_tab.cycle(true);
16046 assert_eq!(state.inventory_tab, InventoryTab::Nearby);
16047 assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
16049 assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
16050 assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
16051 assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
16052 }
16053
16054 #[test]
16055 fn parse_bank_copper_amount_blank_and_zero_mean_all() {
16056 assert_eq!(parse_bank_copper_amount(""), Some(0));
16057 assert_eq!(parse_bank_copper_amount(" "), Some(0));
16058 assert_eq!(parse_bank_copper_amount("0"), Some(0));
16059 assert_eq!(parse_bank_copper_amount("250"), Some(250));
16060 assert_eq!(parse_bank_copper_amount("nope"), None);
16061 }
16062
16063 #[test]
16064 fn parse_storage_quantity_blank_and_zero_mean_all() {
16065 assert_eq!(parse_storage_quantity(""), Some(None));
16066 assert_eq!(parse_storage_quantity(" "), Some(None));
16067 assert_eq!(parse_storage_quantity("0"), Some(None));
16068 assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
16069 assert_eq!(parse_storage_quantity("nope"), None);
16070 }
16071
16072 #[test]
16073 fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
16074 assert!(worker_error_is_hud_noise("path stuck — repathing"));
16075 assert!(worker_error_is_hud_noise("path stuck — nudged clear, repathing"));
16076 assert!(worker_error_is_hud_noise("returned to lodging after path failures"));
16077 assert!(!worker_error_is_hud_noise(
16079 "path stuck — no lodging to reset to"
16080 ));
16081 }
16082
16083 #[test]
16084 fn leaving_building_restores_outdoor_z_bands() {
16085 use flatland_protocol::{InteriorMapView, ZPlatformView};
16086
16087 let mut state = sample_state();
16088 state.z_platforms.clear();
16089 state.z_transitions.clear();
16090 state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
16091 state.interior_map = Some(InteriorMapView {
16092 building_id: "broker_hut".into(),
16093 blueprint_id: "broker_hut".into(),
16094 background_color: "#000".into(),
16095 default_floor_color: None,
16096 floor_height_m: 3.0,
16097 z_platforms: vec![ZPlatformView {
16098 id: "floor_0".into(),
16099 z: 0.0,
16100 x0: 0.0,
16101 y0: 0.0,
16102 x1: 8.0,
16103 y1: 8.0,
16104 }],
16105 z_transitions: vec![],
16106 rooms: vec![],
16107 room_doors: vec![],
16108 });
16109 state.sync_interior_map_context();
16110 assert_eq!(state.z_platforms.len(), 1, "indoors installs interior platforms");
16111 assert!(state.z_bands_outdoor_backup.is_some());
16112
16113 state.player.as_mut().unwrap().inside_building = None;
16114 state.sync_interior_map_context();
16115 assert!(
16116 state.z_platforms.is_empty(),
16117 "leaving must restore outdoor bands (empty), not leave interior platforms"
16118 );
16119 assert!(state.z_bands_outdoor_backup.is_none());
16120 assert!(state.interior_map.is_none());
16121 }
16122}