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 ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1332 pub hotbar: Vec<Option<String>>,
1334 pub max_abilities_per_rotation: u8,
1336 pub show_loadout_menu: bool,
1337 pub show_keychain_menu: bool,
1338 pub keychain_menu_index: usize,
1339 pub show_rotation_editor: bool,
1340 pub loadout_menu_index: usize,
1342 pub loadout_hotbar_slot: u8,
1344 pub loadout_ability_index: usize,
1346 pub loadout_focus_presets: bool,
1348 pub rotation_editor: RotationEditorState,
1349 pub harvest_in_progress: bool,
1351 pub harvest_started_at: Option<Instant>,
1353 pub pending_craft_ack: Option<(u32, String, u32)>,
1355 pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1356 pub interactables: Vec<flatland_protocol::InteractableView>,
1357 pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1358 pub career: Option<flatland_protocol::PlayerCareerView>,
1359 pub character_sheet_tab: CharacterSheetTab,
1360 pub ledger_period: LedgerPeriod,
1361 pub show_quest_offer: bool,
1362 pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1363 pub show_quest_menu: bool,
1364 pub quest_menu_index: usize,
1365 pub quest_withdraw_confirm: bool,
1366 pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1367 pub show_workers_menu: bool,
1368 pub workers_menu_index: usize,
1369 pub workers_menu_compact: bool,
1371 pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1374 pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1376 pub show_worker_give_picker: bool,
1378 pub worker_give_picker_index: usize,
1379 pub worker_give_picker: Option<WorkerGivePicker>,
1380 pub show_worker_give_target_picker: bool,
1382 pub worker_give_target_picker_index: usize,
1383 pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1384 pub show_worker_take_picker: bool,
1386 pub worker_take_picker_index: usize,
1387 pub worker_take_picker: Option<WorkerTakePicker>,
1388 pub show_worker_teach_picker: bool,
1390 pub worker_teach_picker_index: usize,
1391 pub worker_teach_picker: Option<WorkerTeachPicker>,
1392 pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1394 pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1396 pub attending_worker_instance_id: Option<String>,
1398 pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1400}
1401
1402impl GameState {
1403 pub fn push_log(&mut self, line: impl Into<String>) {
1404 self.logs.push_back(line.into());
1405 while self.logs.len() > MAX_LOG_LINES {
1406 self.logs.pop_front();
1407 }
1408 }
1409
1410 pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1411 self.shop_trade_log.push_back(line.into());
1412 while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1413 self.shop_trade_log.pop_front();
1414 }
1415 }
1416
1417 pub fn clear_shop_trade_log(&mut self) {
1418 self.shop_trade_log.clear();
1419 }
1420
1421 fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1422 if !self.show_shop_menu {
1423 return;
1424 }
1425 let msg = notice.message.trim();
1426 if msg.is_empty() {
1427 return;
1428 }
1429 if notice.coins_delta != 0
1430 || msg.starts_with("Bought ")
1431 || msg.starts_with("Sold ")
1432 || msg.contains("taught you how to craft")
1433 || msg.starts_with("need ")
1434 {
1435 self.push_shop_trade_log(msg);
1436 }
1437 }
1438
1439 pub fn is_alive(&self) -> bool {
1440 self.player
1441 .as_ref()
1442 .and_then(|p| p.vitals)
1443 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1444 .unwrap_or(true)
1445 }
1446
1447 pub fn npc_verb_options(&self) -> Vec<&'static str> {
1449 let Some(ref id) = self.npc_verb_target else {
1450 return vec![];
1451 };
1452 let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1453 return vec!["Talk"];
1454 };
1455 let role = npc.role.as_str();
1456 if Self::npc_role_is_bank(role) {
1457 return vec!["Bank", "Talk"];
1458 }
1459 if Self::npc_role_is_storage(role) {
1460 return vec!["Storage", "Talk"];
1461 }
1462 if Self::npc_role_is_market(role) {
1463 return vec!["Market", "Talk"];
1464 }
1465 if npc.can_trade || Self::npc_role_can_trade(role) {
1466 vec!["Talk", "Trade"]
1467 } else {
1468 vec!["Talk"]
1469 }
1470 }
1471
1472 fn npc_role_can_trade(role: &str) -> bool {
1473 matches!(role, "broker" | "cook" | "farmer" | "merchant")
1474 }
1475
1476 fn npc_role_is_bank(role: &str) -> bool {
1477 role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1478 }
1479
1480 fn npc_role_is_storage(role: &str) -> bool {
1481 role.eq_ignore_ascii_case("storage_manager")
1482 }
1483
1484 fn npc_role_is_market(role: &str) -> bool {
1485 role.eq_ignore_ascii_case("market_clerk")
1486 }
1487
1488 pub fn bank_menu_options(&self) -> Vec<&'static str> {
1489 vec![
1490 "Deposit…",
1491 "Withdraw…",
1492 "Deposit all",
1493 "Withdraw all",
1494 "Transfer…",
1495 ]
1496 }
1497
1498 pub fn storage_menu_options(&self) -> Vec<String> {
1499 let mut opts = vec!["Store…".into(), "Take…".into()];
1500 if let Some(panel) = &self.storage_panel {
1501 for dest in &panel.ship_destinations {
1502 opts.push(format!(
1503 "Ship → {} ({} cp / {} ticks)",
1504 dest.label, dest.fee_copper, dest.travel_ticks
1505 ));
1506 }
1507 }
1508 opts
1509 }
1510
1511 pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1513 self.person_rows()
1514 .into_iter()
1515 .filter(|r| r.depth == 0)
1516 .filter_map(|r| {
1517 let id = r.stack.item_instance_id?;
1518 Some(StoragePickOption {
1519 item_instance_id: id,
1520 label: storage_stack_label(&r.stack),
1521 quantity: r.stack.quantity,
1522 category: r.stack.category.clone().unwrap_or_default(),
1523 })
1524 })
1525 .collect()
1526 }
1527
1528 pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1530 let Some(panel) = &self.storage_panel else {
1531 return Vec::new();
1532 };
1533 panel
1534 .contents
1535 .iter()
1536 .filter_map(|s| {
1537 let id = s.item_instance_id?;
1538 Some(StoragePickOption {
1539 item_instance_id: id,
1540 label: storage_stack_label(s),
1541 quantity: s.quantity,
1542 category: s.category.clone().unwrap_or_default(),
1543 })
1544 })
1545 .collect()
1546 }
1547
1548 pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
1550 let mut opts = Vec::new();
1551 if !self
1552 .market_list_item_options(&MarketListSourceKind::Person)
1553 .is_empty()
1554 {
1555 opts.push((MarketListSourceKind::Person, "On person".into()));
1556 }
1557 if let Some(panel) = &self.market_panel {
1558 for vault in &panel.list_vaults {
1559 let source = MarketListSourceKind::TownStorage {
1560 building_id: vault.building_id.clone(),
1561 };
1562 if self.market_list_item_options(&source).is_empty() {
1563 continue;
1564 }
1565 let label = if vault.building_label.is_empty() {
1566 format!("Town storage ({})", vault.building_id)
1567 } else {
1568 format!("Town storage — {}", vault.building_label)
1569 };
1570 opts.push((source, label));
1571 }
1572 }
1573 opts
1574 }
1575
1576 pub fn market_list_item_options(
1578 &self,
1579 source: &MarketListSourceKind,
1580 ) -> Vec<StoragePickOption> {
1581 let filter = self.market_filter.as_str();
1582 let cat_filter = self.market_category_filter;
1583 let mut opts: Vec<StoragePickOption> = match source {
1584 MarketListSourceKind::Person => self
1585 .person_rows()
1586 .into_iter()
1587 .filter(|r| r.depth == 0)
1588 .filter(|r| self.stack_is_market_listable(&r.stack))
1589 .filter_map(|r| {
1590 let id = r.stack.item_instance_id?;
1591 Some(StoragePickOption {
1592 item_instance_id: id,
1593 label: storage_stack_label(&r.stack),
1594 quantity: r.stack.quantity,
1595 category: r
1596 .stack
1597 .category
1598 .clone()
1599 .or_else(|| {
1600 self.inventory_item_category(&r.stack.template_id)
1601 .map(str::to_string)
1602 })
1603 .unwrap_or_default(),
1604 })
1605 })
1606 .collect(),
1607 MarketListSourceKind::TownStorage { building_id } => {
1608 let Some(panel) = &self.market_panel else {
1609 return Vec::new();
1610 };
1611 let Some(vault) = panel
1612 .list_vaults
1613 .iter()
1614 .find(|v| &v.building_id == building_id)
1615 else {
1616 return Vec::new();
1617 };
1618 vault
1619 .contents
1620 .iter()
1621 .filter(|s| self.stack_is_market_listable(s))
1622 .filter_map(|s| {
1623 let id = s.item_instance_id?;
1624 Some(StoragePickOption {
1625 item_instance_id: id,
1626 label: storage_stack_label(s),
1627 quantity: s.quantity,
1628 category: s
1629 .category
1630 .clone()
1631 .or_else(|| {
1632 self.inventory_item_category(&s.template_id)
1633 .map(str::to_string)
1634 })
1635 .unwrap_or_default(),
1636 })
1637 })
1638 .collect()
1639 }
1640 };
1641 opts.retain(|o| {
1642 if !list_label_matches(&o.label, filter) {
1643 return false;
1644 }
1645 if let Some(group) = cat_filter {
1646 inventory_category_group(&o.category).0 == group
1647 } else {
1648 true
1649 }
1650 });
1651 opts
1652 }
1653
1654 fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
1655 if crate::currency::is_currency(&stack.template_id) {
1656 return false;
1657 }
1658 if let Some(flag) = stack.listable {
1659 return flag;
1660 }
1661 if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
1662 return hint.listable;
1663 }
1664 let cat = stack
1665 .category
1666 .as_deref()
1667 .or_else(|| self.inventory_item_category(&stack.template_id))
1668 .unwrap_or("");
1669 category_default_listable(cat)
1670 }
1671
1672 pub fn market_available_category_groups(&self) -> Vec<&'static str> {
1674 let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
1675 match &self.market_ui_mode {
1676 MarketUiMode::ListPick { source, .. } => {
1677 let raw: Vec<_> = match source {
1678 MarketListSourceKind::Person => self
1679 .person_rows()
1680 .into_iter()
1681 .filter(|r| r.depth == 0)
1682 .filter(|r| self.stack_is_market_listable(&r.stack))
1683 .filter(|r| list_label_matches(&storage_stack_label(&r.stack), &self.market_filter))
1684 .map(|r| {
1685 r.stack
1686 .category
1687 .clone()
1688 .or_else(|| {
1689 self.inventory_item_category(&r.stack.template_id)
1690 .map(str::to_string)
1691 })
1692 .unwrap_or_default()
1693 })
1694 .collect(),
1695 MarketListSourceKind::TownStorage { building_id } => self
1696 .market_panel
1697 .as_ref()
1698 .and_then(|p| {
1699 p.list_vaults
1700 .iter()
1701 .find(|v| &v.building_id == building_id)
1702 })
1703 .map(|vault| {
1704 vault
1705 .contents
1706 .iter()
1707 .filter(|s| self.stack_is_market_listable(s))
1708 .filter(|s| {
1709 list_label_matches(&storage_stack_label(s), &self.market_filter)
1710 })
1711 .map(|s| {
1712 s.category
1713 .clone()
1714 .or_else(|| {
1715 self.inventory_item_category(&s.template_id)
1716 .map(str::to_string)
1717 })
1718 .unwrap_or_default()
1719 })
1720 .collect::<Vec<_>>()
1721 })
1722 .unwrap_or_default(),
1723 };
1724 for category in raw {
1725 let (label, ord) = inventory_category_group(&category);
1726 seen.insert(ord, label);
1727 }
1728 }
1729 _ => {
1730 if let Some(panel) = &self.market_panel {
1731 for listing in &panel.listings {
1732 if !list_label_matches(&listing.display_name, &self.market_filter)
1733 && !list_label_matches(&listing.seller_label, &self.market_filter)
1734 {
1735 continue;
1736 }
1737 let (label, ord) = inventory_category_group(&listing.category);
1738 seen.insert(ord, label);
1739 }
1740 }
1741 }
1742 }
1743 seen.into_values().collect()
1744 }
1745
1746 pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
1748 let Some(panel) = &self.market_panel else {
1749 return Vec::new();
1750 };
1751 let filter = self.market_filter.as_str();
1752 let cat_filter = self.market_category_filter;
1753 panel
1754 .listings
1755 .iter()
1756 .enumerate()
1757 .filter(|(_, listing)| {
1758 if !list_label_matches(&listing.display_name, filter)
1759 && !list_label_matches(&listing.seller_label, filter)
1760 && !list_label_matches(&listing.template_id, filter)
1761 {
1762 return false;
1763 }
1764 if let Some(group) = cat_filter {
1765 inventory_category_group(&listing.category).0 == group
1766 } else {
1767 true
1768 }
1769 })
1770 .map(|(i, _)| i)
1771 .collect()
1772 }
1773
1774 pub fn clear_harvest_state(&mut self) {
1775 self.harvest_in_progress = false;
1776 self.harvest_started_at = None;
1777 }
1778
1779 fn harvest_state_stale(&self) -> bool {
1780 match self.harvest_started_at {
1781 Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
1782 None => self.harvest_in_progress,
1783 }
1784 }
1785
1786 pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
1787 self.player.as_ref().and_then(|p| p.vitals)
1788 }
1789
1790 pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
1791 let materials_ok = blueprint.inputs.iter().all(|input| {
1792 self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
1793 });
1794 let tools_ok = blueprint
1795 .required_tools
1796 .iter()
1797 .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
1798 let station_ok = match blueprint.station.as_deref() {
1799 None | Some("hand") => true,
1800 Some(tag) => self.player_at_station_tag(tag),
1801 };
1802 materials_ok && tools_ok && station_ok
1803 }
1804
1805 pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
1806 if !self.can_craft_blueprint(blueprint) {
1807 return 0;
1808 }
1809 let mut limit = u32::MAX;
1810 for input in &blueprint.inputs {
1811 if input.quantity == 0 {
1812 continue;
1813 }
1814 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1815 limit = limit.min(have / input.quantity);
1816 }
1817 for tool in &blueprint.required_tools {
1818 if tool.consumed {
1819 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1820 limit = limit.min(have);
1821 }
1822 }
1823 let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
1824 if CRAFT_STAMINA_COST > 0.0 {
1825 limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
1826 }
1827 limit
1828 }
1829
1830 pub fn clamp_craft_batch_quantity(&mut self) {
1831 let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
1832 self.craft_batch_quantity = 1;
1833 return;
1834 };
1835 let max = self.max_craft_batches(bp).max(1);
1836 self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
1837 }
1838
1839 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
1840 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1841 return;
1842 };
1843 let max = self.max_craft_batches(&bp).max(1);
1844 let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
1845 self.craft_batch_quantity = next as u32;
1846 }
1847
1848 pub fn craft_batch_set_max(&mut self) {
1849 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1850 return;
1851 };
1852 let max = self.max_craft_batches(&bp);
1853 self.craft_batch_quantity = if max == 0 { 1 } else { max };
1854 }
1855
1856 pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
1857 let preserve_ui = self.show_shop_menu;
1858 let tab = self.shop_tab;
1859 let index = self.shop_menu_index;
1860 let qty = self.shop_quantity;
1861
1862 self.show_shop_menu = true;
1863 self.bank_panel = None;
1864 self.show_craft_menu = false;
1865 self.show_inventory_menu = false;
1866 self.show_stats = false;
1867 if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
1868 self.npc_verb_target = Some(catalog.npc_id.clone());
1869 }
1870 self.shop_catalog = Some(catalog);
1871
1872 if preserve_ui {
1873 self.shop_tab = tab;
1874 self.shop_menu_index = index;
1875 self.shop_quantity = qty;
1876 } else {
1877 self.shop_tab = ShopTab::Buy;
1878 self.shop_menu_index = 0;
1879 self.shop_quantity = 1;
1880 self.clear_shop_trade_log();
1881 }
1882 self.show_npc_verb_menu = false;
1883 self.clamp_shop_selection();
1884 }
1885
1886 pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
1887 let same_teller = self
1888 .bank_panel
1889 .as_ref()
1890 .is_some_and(|p| p.npc_id == panel.npc_id);
1891 self.bank_panel = Some(panel);
1892 self.storage_panel = None;
1893 self.market_panel = None;
1894 self.shop_catalog = None;
1895 self.show_shop_menu = false;
1896 self.show_craft_menu = false;
1897 self.show_inventory_menu = false;
1898 self.show_stats = false;
1899 self.show_npc_verb_menu = false;
1900 self.show_npc_chat = false;
1901 self.npc_chat = None;
1902 if !same_teller {
1903 self.bank_menu_index = 0;
1904 self.bank_ui_mode = BankUiMode::Menu;
1905 }
1906 if let Some(panel) = &self.bank_panel {
1907 if self.npc_verb_target.is_none() {
1908 self.npc_verb_target = Some(panel.npc_id.clone());
1909 }
1910 }
1911 }
1912
1913 pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
1914 let same_manager = self
1915 .storage_panel
1916 .as_ref()
1917 .is_some_and(|p| p.npc_id == panel.npc_id);
1918 self.storage_panel = Some(panel);
1919 self.bank_panel = None;
1920 self.market_panel = None;
1921 self.bank_ui_mode = BankUiMode::Menu;
1922 self.shop_catalog = None;
1923 self.show_shop_menu = false;
1924 self.show_craft_menu = false;
1925 self.show_inventory_menu = false;
1926 self.show_stats = false;
1927 self.show_npc_verb_menu = false;
1928 self.show_npc_chat = false;
1929 self.npc_chat = None;
1930 if !same_manager {
1931 self.storage_menu_index = 0;
1932 self.storage_ui_mode = StorageUiMode::Menu;
1933 } else {
1934 self.clamp_storage_pick_index();
1935 }
1936 if let Some(panel) = &self.storage_panel {
1937 if self.npc_verb_target.is_none() {
1938 self.npc_verb_target = Some(panel.npc_id.clone());
1939 }
1940 }
1941 }
1942
1943 pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
1944 self.market_panel = Some(panel);
1945 self.bank_panel = None;
1946 self.storage_panel = None;
1947 self.shop_catalog = None;
1948 self.show_shop_menu = false;
1949 self.show_craft_menu = false;
1950 self.show_inventory_menu = false;
1951 self.show_stats = false;
1952 self.show_npc_verb_menu = false;
1953 self.show_npc_chat = false;
1954 self.npc_chat = None;
1955 self.market_menu_index = 0;
1956 self.market_buy_confirm = None;
1957 self.market_ui_mode = MarketUiMode::Browse;
1958 self.market_filter.clear();
1959 self.market_filter_focused = false;
1960 self.market_category_filter = None;
1961 if let Some(panel) = &self.market_panel {
1962 if self.npc_verb_target.is_none() {
1963 self.npc_verb_target = Some(panel.npc_id.clone());
1964 }
1965 }
1966 }
1967
1968 pub fn clear_market_panel(&mut self) {
1969 self.market_panel = None;
1970 self.market_menu_index = 0;
1971 self.market_buy_confirm = None;
1972 self.market_ui_mode = MarketUiMode::Browse;
1973 self.market_filter.clear();
1974 self.market_filter_focused = false;
1975 self.market_category_filter = None;
1976 }
1977
1978 pub fn clear_bank_panel(&mut self) {
1979 self.bank_panel = None;
1980 self.bank_menu_index = 0;
1981 self.bank_ui_mode = BankUiMode::Menu;
1982 }
1983
1984 pub fn clear_storage_panel(&mut self) {
1985 self.storage_panel = None;
1986 self.storage_menu_index = 0;
1987 self.storage_ui_mode = StorageUiMode::Menu;
1988 }
1989
1990 fn clamp_storage_pick_index(&mut self) {
1991 match &self.storage_ui_mode {
1992 StorageUiMode::StorePick { index } => {
1993 let n = self.storage_store_options().len();
1994 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1995 self.storage_ui_mode = StorageUiMode::StorePick { index: next };
1996 }
1997 StorageUiMode::TakePick { index } => {
1998 let n = self.storage_vault_options().len();
1999 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2000 self.storage_ui_mode = StorageUiMode::TakePick { index: next };
2001 }
2002 StorageUiMode::ShipPick {
2003 dest_building_id,
2004 dest_label,
2005 index,
2006 } => {
2007 let n = self.storage_vault_options().len();
2008 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2009 self.storage_ui_mode = StorageUiMode::ShipPick {
2010 dest_building_id: dest_building_id.clone(),
2011 dest_label: dest_label.clone(),
2012 index: next,
2013 };
2014 }
2015 StorageUiMode::Menu
2016 | StorageUiMode::StoreAmount { .. }
2017 | StorageUiMode::TakeAmount { .. }
2018 | StorageUiMode::ShipAmount { .. } => {}
2019 }
2020 }
2021
2022 pub fn shop_list_len(&self) -> usize {
2023 let Some(catalog) = &self.shop_catalog else {
2024 return 0;
2025 };
2026 match self.shop_tab {
2027 ShopTab::Buy => catalog.sells.len(),
2028 ShopTab::Sell => catalog.buys.len(),
2029 }
2030 }
2031
2032 pub fn shop_menu_move(&mut self, delta: i32) {
2033 let n = self.shop_list_len();
2034 if n == 0 {
2035 return;
2036 }
2037 let idx = self.shop_menu_index as i32;
2038 let next = (idx + delta).rem_euclid(n as i32);
2039 self.shop_menu_index = next as usize;
2040 self.clamp_shop_quantity();
2041 }
2042
2043 pub fn shop_quantity_adjust(&mut self, delta: i32) {
2044 let max = self.shop_quantity_max();
2045 if max == 0 {
2046 self.shop_quantity = 0;
2047 return;
2048 }
2049 let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
2050 self.shop_quantity = next as u32;
2051 }
2052
2053 pub(crate) fn clamp_shop_selection(&mut self) {
2054 let n = self.shop_list_len();
2055 if n == 0 {
2056 self.shop_menu_index = 0;
2057 } else {
2058 self.shop_menu_index = self.shop_menu_index.min(n - 1);
2059 }
2060 self.clamp_shop_quantity();
2061 }
2062
2063 fn shop_quantity_max(&self) -> u32 {
2064 let Some(catalog) = &self.shop_catalog else {
2065 return 1;
2066 };
2067 match self.shop_tab {
2068 ShopTab::Buy => {
2069 if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
2070 if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
2071 return 1;
2072 }
2073 }
2074 99
2075 }
2076 ShopTab::Sell => catalog
2077 .buys
2078 .get(self.shop_menu_index)
2079 .map(|l| l.quantity)
2080 .unwrap_or(0),
2081 }
2082 }
2083
2084 pub fn shop_quantity_set_max(&mut self) {
2085 self.shop_quantity = self.shop_quantity_max();
2086 }
2087
2088 fn clamp_shop_quantity(&mut self) {
2089 let max = self.shop_quantity_max();
2090 if max == 0 {
2091 self.shop_quantity = 0;
2092 } else {
2093 self.shop_quantity = self.shop_quantity.max(1).min(max);
2094 }
2095 }
2096
2097 pub fn player_at_station_tag(&self, tag: &str) -> bool {
2098 let Some(id) = self.effective_inside_building() else {
2099 return false;
2100 };
2101 self.buildings
2102 .iter()
2103 .find(|b| b.id == id)
2104 .is_some_and(|b| b.tags.iter().any(|t| t == tag))
2105 }
2106
2107 pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
2109 if self.can_craft_blueprint(blueprint) {
2110 return None;
2111 }
2112 let mut missing = Vec::new();
2113 for input in &blueprint.inputs {
2114 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2115 if have < input.quantity {
2116 missing.push(format!(
2117 "{}×{} (have {have})",
2118 input.quantity, input.template_id
2119 ));
2120 }
2121 }
2122 for tool in &blueprint.required_tools {
2123 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2124 if have < 1 {
2125 missing.push(format!("tool: {}", tool.item));
2126 }
2127 }
2128 if let Some(station) = blueprint.station.as_deref() {
2129 if station != "hand" && !self.player_at_station_tag(station) {
2130 missing.push(format!("station: {station} (enter building)"));
2131 }
2132 }
2133 if missing.is_empty() {
2134 None
2135 } else {
2136 Some(missing.join(", "))
2137 }
2138 }
2139
2140 pub fn player_entity(&self) -> Option<&EntityState> {
2141 self.player
2142 .as_ref()
2143 .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
2144 }
2145
2146 pub fn apply_client_ui_prefs(&mut self) {
2148 let cfg = crate::client_config::ClientConfig::load();
2149 if let Some(hidden) = cfg.hud_log_hidden {
2150 self.hud_log_hidden = hidden;
2151 }
2152 if let Some(compact) = cfg.workers_menu_compact {
2153 self.workers_menu_compact = compact;
2154 }
2155 }
2156
2157 pub fn player_position(&self) -> (f32, f32) {
2158 let (x, y, _) = self.player_position_with_z();
2159 (x, y)
2160 }
2161
2162 pub fn player_position_with_z(&self) -> (f32, f32, f32) {
2163 if let Some(p) = self.player_entity() {
2164 (
2165 p.transform.position.x,
2166 p.transform.position.y,
2167 p.transform.position.z,
2168 )
2169 } else {
2170 (0.0, 0.0, 0.0)
2171 }
2172 }
2173
2174 pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
2175 let mut rows: Vec<(String, u32, String)> = self
2176 .inventory
2177 .iter()
2178 .filter(|(_, q)| **q > 0)
2179 .map(|(id, qty)| {
2180 let label = self
2181 .inventory_hints
2182 .get(id)
2183 .map(|h| h.display_name.clone())
2184 .unwrap_or_else(|| id.clone());
2185 (id.clone(), *qty, label)
2186 })
2187 .collect();
2188 rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
2189 rows
2190 }
2191
2192 pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
2193 self.inventory_hints
2194 .get(template_id)
2195 .map(|h| h.category.as_str())
2196 .filter(|c| !c.is_empty())
2197 }
2198
2199 pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
2200 stack
2201 .props
2202 .get("grants_item_status_effect")
2203 .map(|s| !s.is_empty())
2204 .unwrap_or(false)
2205 }
2206
2207 pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
2208 stack
2209 .props
2210 .get("grants_item_status_effect")
2211 .map(String::as_str)
2212 .filter(|s| !s.is_empty())
2213 }
2214
2215 pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
2216 stack
2217 .props
2218 .get("grants_item_status_mode")
2219 .map(String::as_str)
2220 .unwrap_or("on_hit")
2221 }
2222
2223 pub fn grant_target_options(
2225 &self,
2226 grant: &flatland_protocol::ItemStack,
2227 ) -> Vec<GrantTargetOption> {
2228 let mode = Self::grant_mode(grant);
2229 let grant_tags: Vec<&str> = grant
2230 .props
2231 .get("grants_item_status_tags")
2232 .map(|s| {
2233 s.split(',')
2234 .map(str::trim)
2235 .filter(|t| !t.is_empty())
2236 .collect()
2237 })
2238 .unwrap_or_default();
2239 let grant_id = grant.item_instance_id;
2240 let mut out = Vec::new();
2241 let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
2242 let Some(iid) = stack.item_instance_id else {
2243 return;
2244 };
2245 if Some(iid) == grant_id {
2246 return;
2247 }
2248 if stack.props.get("enchantable").map(String::as_str) == Some("0") {
2249 return;
2250 }
2251 if !grant_target_matches_mode(stack, mode) {
2252 return;
2253 }
2254 if !grant_tags_match(stack, &grant_tags) {
2255 return;
2256 }
2257 let name = stack
2258 .display_name
2259 .clone()
2260 .unwrap_or_else(|| stack.template_id.clone());
2261 let bindings = if stack.status_bindings.is_empty() {
2262 String::new()
2263 } else {
2264 format!(
2265 " · {}",
2266 stack
2267 .status_bindings
2268 .iter()
2269 .map(|b| b.effect_id.as_str())
2270 .collect::<Vec<_>>()
2271 .join(", ")
2272 )
2273 };
2274 out.push(GrantTargetOption {
2275 label: format!("{where_label}: {name}{bindings}"),
2276 target_instance_id: iid,
2277 });
2278 };
2279 fn walk(
2280 stacks: &[flatland_protocol::ItemStack],
2281 where_label: &str,
2282 push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
2283 ) {
2284 for s in stacks {
2285 push(s, where_label);
2286 if !s.contents.is_empty() {
2287 let nested = format!(
2288 "{where_label}/{}",
2289 s.display_name
2290 .as_deref()
2291 .unwrap_or(s.template_id.as_str())
2292 );
2293 walk(&s.contents, &nested, push);
2294 }
2295 }
2296 }
2297 walk(&self.inventory_stacks, "Bag", &mut push);
2298 for (slot, stack) in &self.worn {
2299 push(stack, body_slot_label(*slot));
2300 let nest = format!(
2301 "{}/{}",
2302 body_slot_label(*slot),
2303 stack
2304 .display_name
2305 .as_deref()
2306 .unwrap_or(stack.template_id.as_str())
2307 );
2308 walk(&stack.contents, &nest, &mut push);
2309 }
2310 out
2311 }
2312
2313 pub fn item_base_mass(&self, template_id: &str) -> f32 {
2314 self.inventory_hints
2315 .get(template_id)
2316 .and_then(|h| h.base_mass)
2317 .unwrap_or(0.5)
2318 }
2319
2320 pub fn item_base_volume(&self, template_id: &str) -> f32 {
2321 self.inventory_hints
2322 .get(template_id)
2323 .and_then(|h| h.base_volume)
2324 .unwrap_or(1.0)
2325 }
2326
2327 pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
2328 let unit = stack
2329 .base_mass
2330 .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
2331 unit * stack.quantity as f32
2332 }
2333
2334 fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
2335 let unit = stack.base_volume.unwrap_or(1.0);
2336 unit * stack.quantity as f32
2337 + stack
2338 .contents
2339 .iter()
2340 .map(Self::stack_tree_volume)
2341 .sum::<f32>()
2342 }
2343
2344 fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
2345 contents.iter().map(Self::stack_tree_volume).sum()
2346 }
2347
2348 fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
2349 self.inventory_hints
2350 .get(template_id)
2351 .and_then(|h| h.capacity_volume)
2352 .filter(|c| *c > 0.0)
2353 }
2354
2355 fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
2356 stack
2357 .capacity_volume
2358 .filter(|c| *c > 0.0)
2359 .or_else(|| self.template_capacity_volume(&stack.template_id))
2360 }
2361
2362 pub fn container_volume_label(&self, row: &InventoryRow) -> String {
2364 let Some((used, cap)) = self.container_volume_stats(row) else {
2365 return String::new();
2366 };
2367 let free = (cap - used).max(0.0);
2368 format!(" vol {used:.0}/{cap:.0} ({free:.0} free)")
2369 }
2370
2371 fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
2372 if row.is_chest_shell {
2373 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
2374 return None;
2375 };
2376 let chest = self
2377 .placed_containers
2378 .iter()
2379 .find(|c| c.id == *container_id)?;
2380 let cap = self
2381 .stack_capacity_volume(&row.stack)
2382 .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
2383 let used = if chest.accessible {
2384 Self::contents_used_volume(&chest.contents)
2385 } else {
2386 0.0
2387 };
2388 return Some((used, cap));
2389 }
2390
2391 let cap = self.stack_capacity_volume(&row.stack)?;
2392 let used = Self::contents_used_volume(&row.stack.contents);
2393 Some((used, cap))
2394 }
2395
2396 pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
2397 if row.is_chest_shell {
2398 return true;
2399 }
2400 if row.is_equip_shell {
2401 return self.inventory_item_category(&row.stack.template_id) == Some("container");
2402 }
2403 self.inventory_item_category(&row.stack.template_id) == Some("container")
2404 || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
2405 }
2406
2407 fn container_stack_for(
2408 &self,
2409 location: &flatland_protocol::InventoryLocation,
2410 parent_instance_id: Option<uuid::Uuid>,
2411 ) -> Option<flatland_protocol::ItemStack> {
2412 match location {
2413 flatland_protocol::InventoryLocation::Root => {
2414 let pid = parent_instance_id?;
2415 self.find_stack_by_instance(&self.inventory_stacks, pid)
2416 }
2417 flatland_protocol::InventoryLocation::Worn { slot } => {
2418 let worn = self.worn.get(slot)?;
2419 if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
2420 Some(worn.clone())
2421 } else {
2422 self.find_stack_by_instance(&worn.contents, parent_instance_id?)
2423 }
2424 }
2425 flatland_protocol::InventoryLocation::Placed { container_id } => {
2426 let chest = self
2427 .placed_containers
2428 .iter()
2429 .find(|c| c.id == *container_id)?;
2430 if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
2431 Some(flatland_protocol::ItemStack {
2432 template_id: chest.template_id.clone(),
2433 quantity: 1,
2434 item_instance_id: chest.item_instance_id,
2435 props: Default::default(),
2436 status_bindings: Vec::new(),
2437 contents: chest.contents.clone(),
2438 display_name: Some(chest.display_name.clone()),
2439 category: Some("container".into()),
2440 capacity_volume: self
2441 .inventory_hints
2442 .get(&chest.template_id)
2443 .and_then(|h| h.capacity_volume),
2444 worker_lodging_capacity: chest.worker_lodging_capacity,
2445 ..Default::default()
2446 })
2447 } else {
2448 self.find_stack_by_instance(&chest.contents, parent_instance_id?)
2449 }
2450 }
2451 flatland_protocol::InventoryLocation::Keychain => None,
2452 flatland_protocol::InventoryLocation::WhisperPouch => None,
2453 }
2454 }
2455
2456 fn find_stack_by_instance(
2457 &self,
2458 stacks: &[flatland_protocol::ItemStack],
2459 instance_id: uuid::Uuid,
2460 ) -> Option<flatland_protocol::ItemStack> {
2461 for stack in stacks {
2462 if stack.item_instance_id == Some(instance_id) {
2463 return Some(stack.clone());
2464 }
2465 if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
2466 return Some(found);
2467 }
2468 }
2469 None
2470 }
2471
2472 pub fn max_movable_to(
2474 &self,
2475 template_id: &str,
2476 stack_qty: u32,
2477 from: &flatland_protocol::InventoryLocation,
2478 to: &flatland_protocol::InventoryLocation,
2479 parent_instance_id: Option<uuid::Uuid>,
2480 ) -> u32 {
2481 let unit_vol = self.item_base_volume(template_id);
2482 let unit_mass = self.item_base_mass(template_id);
2483 let mut limit = stack_qty;
2484
2485 if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2486 let cap = parent
2487 .capacity_volume
2488 .or_else(|| {
2489 self.inventory_hints
2490 .get(&parent.template_id)
2491 .and_then(|h| h.capacity_volume)
2492 })
2493 .unwrap_or(0.0);
2494 if cap > 0.0 && unit_vol > 0.0 {
2495 let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2496 limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2497 }
2498 }
2499
2500 let to_person = matches!(
2501 to,
2502 flatland_protocol::InventoryLocation::Root
2503 | flatland_protocol::InventoryLocation::Worn { .. }
2504 );
2505 let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2506 if to_person && from_placed && unit_mass > 0.0 {
2507 let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2508 if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2509 limit = 0;
2510 } else {
2511 limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2512 }
2513 }
2514
2515 limit.max(0).min(stack_qty)
2516 }
2517
2518 pub fn move_picker_max_at_selection(&self) -> u32 {
2519 let Some(picker) = &self.move_picker else {
2520 return 1;
2521 };
2522 let Some(opt) = picker.options.get(self.move_picker_index) else {
2523 return picker.stack_quantity;
2524 };
2525 match &opt.kind {
2526 MoveOptionKind::Cancel
2527 | MoveOptionKind::Drop
2528 | MoveOptionKind::Use
2529 | MoveOptionKind::GrantApply
2530 | MoveOptionKind::SellPlotToCrown { .. }
2531 | MoveOptionKind::PickupPlaced { .. }
2532 | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
2533 MoveOptionKind::Move {
2534 location,
2535 parent_instance_id,
2536 } => self.max_movable_to(
2537 &picker.template_id,
2538 picker.stack_quantity,
2539 &picker.from,
2540 location,
2541 *parent_instance_id,
2542 ),
2543 }
2544 }
2545
2546 pub fn clamp_move_picker_quantity(&mut self) {
2547 let max = self.move_picker_max_at_selection();
2548 if let Some(picker) = &mut self.move_picker {
2549 if max == 0 {
2550 picker.quantity = 1;
2551 } else {
2552 picker.quantity = picker.quantity.clamp(1, max);
2553 }
2554 }
2555 }
2556
2557 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2558 let max = self.move_picker_max_at_selection().max(1);
2559 if let Some(picker) = &mut self.move_picker {
2560 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2561 picker.quantity = next as u32;
2562 }
2563 }
2564
2565 pub fn move_picker_set_quantity_max(&mut self) {
2566 let max = self.move_picker_max_at_selection();
2567 if let Some(picker) = &mut self.move_picker {
2568 picker.quantity = if max == 0 {
2569 1
2570 } else {
2571 max.min(picker.stack_quantity)
2572 };
2573 }
2574 }
2575
2576 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2577 if let Some(picker) = &mut self.destroy_picker {
2578 let max = picker.stack_quantity.max(1);
2579 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2580 picker.quantity = next as u32;
2581 }
2582 }
2583
2584 pub fn destroy_picker_set_quantity_max(&mut self) {
2585 if let Some(picker) = &mut self.destroy_picker {
2586 picker.quantity = picker.stack_quantity.max(1);
2587 }
2588 }
2589
2590 pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2591 let have = self.inventory.get(template_id).copied().unwrap_or(0);
2592 (have, have >= need)
2593 }
2594
2595 pub fn currency_display(&self) -> String {
2596 crate::currency::currency_line(&self.inventory)
2597 }
2598
2599 pub fn in_shallow_water(&self) -> bool {
2601 let (px, py) = self.player_position();
2602 self.terrain_at(px, py)
2603 .is_some_and(|k| k == TerrainKindView::ShallowWater)
2604 }
2605
2606 pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
2607 self.terrain_zone_at(x, y).map(|z| z.kind)
2608 }
2609
2610 pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
2612 use std::cell::RefCell;
2613
2614 const CHUNK: i32 = 8;
2615 thread_local! {
2616 static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
2617 RefCell::new(None);
2618 }
2619
2620 let zones = &self.terrain_zones;
2621 if zones.is_empty() {
2622 return None;
2623 }
2624 if zones.len() <= 48 {
2625 return zones
2626 .iter()
2627 .enumerate()
2628 .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
2629 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
2630 .map(|(_, z)| z);
2631 }
2632
2633 let ptr = zones.as_ptr();
2634 let len = zones.len();
2635 INDEX.with(|cell| {
2636 let mut slot = cell.borrow_mut();
2637 let stale = match slot.as_ref() {
2638 Some((p, l, _)) => *p != ptr || *l != len,
2639 None => true,
2640 };
2641 if stale {
2642 let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
2643 std::collections::HashMap::new();
2644 for (zi, z) in zones.iter().enumerate() {
2645 let x0 = z.x0.min(z.x1).floor() as i32;
2646 let y0 = z.y0.min(z.y1).floor() as i32;
2647 let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
2648 let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
2649 let cx0 = x0.div_euclid(CHUNK);
2650 let cy0 = y0.div_euclid(CHUNK);
2651 let cx1 = x1.div_euclid(CHUNK);
2652 let cy1 = y1.div_euclid(CHUNK);
2653 for cy in cy0..=cy1 {
2654 for cx in cx0..=cx1 {
2655 chunks.entry((cx, cy)).or_default().push(zi);
2656 }
2657 }
2658 }
2659 *slot = Some((ptr, len, chunks));
2660 }
2661 let chunks = &slot.as_ref().expect("index").2;
2662 let cx = (x.floor() as i32).div_euclid(CHUNK);
2663 let cy = (y.floor() as i32).div_euclid(CHUNK);
2664 let mut best: Option<(usize, &TerrainZoneView)> = None;
2665 if let Some(list) = chunks.get(&(cx, cy)) {
2666 for &zi in list {
2667 let Some(z) = zones.get(zi) else { continue };
2668 if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
2669 continue;
2670 }
2671 best = match best {
2672 None => Some((zi, z)),
2673 Some((bi, bz)) => {
2674 if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
2675 Some((zi, z))
2676 } else {
2677 Some((bi, bz))
2678 }
2679 }
2680 };
2681 }
2682 }
2683 best.map(|(_, z)| z)
2684 })
2685 }
2686
2687 pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
2689 self.terrain_zone_at(x, y)
2690 .map(|z| z.elevation)
2691 .unwrap_or(0.0)
2692 }
2693
2694 pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
2696 const TOL: f32 = 0.35;
2697 let mut levels = vec![self.elevation_at(x, y)];
2698 for p in &self.z_platforms {
2699 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2700 levels.push(p.z);
2701 }
2702 }
2703 levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2704 levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
2705 levels
2706 }
2707
2708 pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
2709 const TOL: f32 = 0.35;
2710 self.walkable_levels_at(x, y)
2711 .iter()
2712 .any(|&l| (l - z).abs() <= TOL)
2713 }
2714
2715 pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
2716 let mut top = self.elevation_at(x, y);
2717 for p in &self.z_platforms {
2718 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2719 top = top.max(p.z);
2720 }
2721 }
2722 top
2723 }
2724
2725 pub fn effective_inside_building(&self) -> Option<String> {
2727 self.player_entity().and_then(|p| p.inside_building.clone())
2728 }
2729
2730 pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
2731 self.inventory_stacks = stacks.to_vec();
2732 self.inventory.clear();
2733 self.inventory_hints.clear();
2734 fn walk(
2735 stacks: &[flatland_protocol::ItemStack],
2736 inventory: &mut std::collections::HashMap<String, u32>,
2737 hints: &mut std::collections::HashMap<String, InventoryHint>,
2738 ) {
2739 for stack in stacks {
2740 *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
2741 if stack.display_name.is_some()
2742 || stack.category.is_some()
2743 || stack.base_mass.is_some()
2744 || stack.base_volume.is_some()
2745 {
2746 hints.insert(
2747 stack.template_id.clone(),
2748 InventoryHint {
2749 display_name: stack
2750 .display_name
2751 .clone()
2752 .unwrap_or_else(|| stack.template_id.clone()),
2753 category: stack.category.clone().unwrap_or_default(),
2754 base_mass: stack.base_mass,
2755 base_volume: stack.base_volume,
2756 capacity_volume: stack.capacity_volume,
2757 stackable: stack.stackable.unwrap_or(true),
2758 listable: stack.listable.unwrap_or_else(|| {
2759 category_default_listable(
2760 stack.category.as_deref().unwrap_or(""),
2761 )
2762 }),
2763 },
2764 );
2765 }
2766 walk(&stack.contents, inventory, hints);
2767 }
2768 }
2769 walk(stacks, &mut self.inventory, &mut self.inventory_hints);
2770 for item in self.worn.values() {
2772 walk(
2773 std::slice::from_ref(item),
2774 &mut self.inventory,
2775 &mut self.inventory_hints,
2776 );
2777 }
2778 }
2779
2780 pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
2783 let subtract_items =
2784 notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
2785 for stack in ¬ice.inventory_delta {
2786 if stack.quantity == 0 {
2787 continue;
2788 }
2789 if subtract_items {
2790 crate::currency::drain_template_stacks(
2791 &mut self.inventory_stacks,
2792 &stack.template_id,
2793 stack.quantity,
2794 );
2795 continue;
2796 }
2797 let stackable = self
2798 .inventory_hints
2799 .get(&stack.template_id)
2800 .map(|h| h.stackable)
2801 .or(stack.stackable)
2802 .unwrap_or(true);
2803 if stackable {
2804 if let Some(existing) = self
2805 .inventory_stacks
2806 .iter_mut()
2807 .find(|s| s.template_id == stack.template_id)
2808 {
2809 existing.quantity = existing.quantity.saturating_add(stack.quantity);
2810 if stack.display_name.is_some() {
2811 existing.display_name = stack.display_name.clone();
2812 }
2813 if stack.category.is_some() {
2814 existing.category = stack.category.clone();
2815 }
2816 continue;
2817 }
2818 }
2819 self.inventory_stacks.push(stack.clone());
2820 }
2821 if notice.coins_delta != 0 {
2822 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
2823 }
2824 if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
2825 let stacks = self.inventory_stacks.clone();
2826 self.sync_inventory_from_stacks(&stacks);
2827 }
2828 self.record_shop_trade_notice(notice);
2829 }
2830
2831 pub fn worn_rows(&self) -> Vec<InventoryRow> {
2836 let mut rows = Vec::new();
2837 for (slot, item) in &self.worn {
2838 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2839 rows.push(InventoryRow {
2840 depth: 0,
2841 stack: item.clone(),
2842 from: from.clone(),
2843 from_parent_instance_id: None,
2844 is_equip_shell: true,
2845 is_chest_shell: false,
2846 section: InventorySection::Worn,
2847 });
2848 for child in &item.contents {
2849 push_inventory_rows(
2850 &mut rows,
2851 1,
2852 child,
2853 &from,
2854 item.item_instance_id,
2855 InventorySection::Worn,
2856 );
2857 }
2858 }
2859 rows
2860 }
2861
2862 pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
2864 self.inventory_stacks
2865 .iter()
2866 .filter_map(|stack| {
2867 let item_instance_id = stack.item_instance_id?;
2868 let label = stack
2869 .display_name
2870 .clone()
2871 .unwrap_or_else(|| stack.template_id.clone());
2872 let label = if stack.quantity > 1 {
2873 format!("{label} ×{}", stack.quantity)
2874 } else {
2875 label
2876 };
2877 Some(WorkerGiveOption {
2878 item_instance_id,
2879 label,
2880 quantity: stack.quantity,
2881 template_id: stack.template_id.clone(),
2882 })
2883 })
2884 .collect()
2885 }
2886
2887 pub fn teachable_blueprint_options(
2889 &self,
2890 worker: &flatland_protocol::HiredWorkerView,
2891 ) -> Vec<WorkerTeachOption> {
2892 let copper = crate::currency::copper_from_counts(&self.inventory);
2893 let mut options: Vec<WorkerTeachOption> = self
2894 .blueprints
2895 .iter()
2896 .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
2897 .map(|bp| {
2898 let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
2899 let cost = bp.worker_train_copper;
2900 WorkerTeachOption {
2901 blueprint_id: bp.id.clone(),
2902 label: if bp.label.is_empty() {
2903 bp.id.clone()
2904 } else {
2905 bp.label.clone()
2906 },
2907 cost_copper: cost,
2908 min_level,
2909 worker_level: worker.level,
2910 can_afford: copper >= cost,
2911 level_ok: worker.level >= min_level,
2912 }
2913 })
2914 .collect();
2915 options.sort_by(|a, b| a.label.cmp(&b.label));
2916 options
2917 }
2918
2919 pub fn person_rows(&self) -> Vec<InventoryRow> {
2922 self.person_rows_filtered("")
2923 }
2924
2925 pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2926 let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
2927 roots.sort_by(|a, b| {
2928 let ca = a
2929 .category
2930 .as_deref()
2931 .or_else(|| self.inventory_item_category(&a.template_id))
2932 .unwrap_or("");
2933 let cb = b
2934 .category
2935 .as_deref()
2936 .or_else(|| self.inventory_item_category(&b.template_id))
2937 .unwrap_or("");
2938 let ga = inventory_category_group(ca).1;
2939 let gb = inventory_category_group(cb).1;
2940 ga.cmp(&gb).then_with(|| {
2941 let na = a
2942 .display_name
2943 .as_deref()
2944 .unwrap_or(a.template_id.as_str());
2945 let nb = b
2946 .display_name
2947 .as_deref()
2948 .unwrap_or(b.template_id.as_str());
2949 na.cmp(nb)
2950 })
2951 });
2952 let mut rows = Vec::new();
2953 for stack in roots {
2954 push_inventory_rows_filtered(
2955 &mut rows,
2956 0,
2957 stack,
2958 &flatland_protocol::InventoryLocation::Root,
2959 None,
2960 InventorySection::Person,
2961 filter,
2962 );
2963 }
2964 rows
2965 }
2966
2967 pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2968 if filter.is_empty() {
2969 return self.worn_rows();
2970 }
2971 let mut rows = Vec::new();
2972 for (slot, item) in &self.worn {
2973 if !stack_matches_filter(item, filter) {
2974 continue;
2975 }
2976 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2977 let self_hit = {
2978 let f = filter.to_ascii_lowercase();
2979 let name = item
2980 .display_name
2981 .as_deref()
2982 .unwrap_or("")
2983 .to_ascii_lowercase();
2984 let tid = item.template_id.to_ascii_lowercase();
2985 name.contains(&f) || tid.contains(&f)
2986 };
2987 rows.push(InventoryRow {
2988 depth: 0,
2989 stack: item.clone(),
2990 from: from.clone(),
2991 from_parent_instance_id: None,
2992 is_equip_shell: true,
2993 is_chest_shell: false,
2994 section: InventorySection::Worn,
2995 });
2996 for child in &item.contents {
2997 if self_hit || stack_matches_filter(child, filter) {
2998 push_inventory_rows_filtered(
2999 &mut rows,
3000 1,
3001 child,
3002 &from,
3003 item.item_instance_id,
3004 InventorySection::Worn,
3005 if self_hit { "" } else { filter },
3006 );
3007 }
3008 }
3009 }
3010 rows
3011 }
3012
3013 pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
3015 let mut rows = self.worn_rows();
3016 rows.extend(self.person_rows());
3017 rows.into_iter().map(|r| (r.depth, r.stack)).collect()
3018 }
3019
3020 pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
3024 let (px, py) = self.player_position();
3025 let mut list: Vec<NearbyContainer> = self
3026 .placed_containers
3027 .iter()
3028 .filter_map(|c| {
3029 let distance_m = (c.x - px).hypot(c.y - py);
3030 if distance_m > CONTAINER_RANGE_M {
3031 return None;
3032 }
3033 let mut rows = Vec::new();
3034 let from = flatland_protocol::InventoryLocation::Placed {
3035 container_id: c.id.clone(),
3036 };
3037 rows.push(InventoryRow {
3038 depth: 0,
3039 stack: flatland_protocol::ItemStack {
3040 template_id: c.template_id.clone(),
3041 quantity: 1,
3042 item_instance_id: c.item_instance_id,
3043 props: Default::default(),
3044 status_bindings: Vec::new(),
3045 contents: Vec::new(),
3046 display_name: Some(c.display_name.clone()),
3047 category: Some("container".into()),
3048 capacity_volume: c.capacity_volume,
3049 worker_lodging_capacity: c.worker_lodging_capacity,
3050 ..Default::default()
3051 },
3052 from: from.clone(),
3053 from_parent_instance_id: None,
3054 is_equip_shell: false,
3055 is_chest_shell: true,
3056 section: InventorySection::Nearby,
3057 });
3058 if c.accessible {
3059 for child in &c.contents {
3060 push_inventory_rows(
3061 &mut rows,
3062 1,
3063 child,
3064 &from,
3065 c.item_instance_id,
3066 InventorySection::Nearby,
3067 );
3068 }
3069 }
3070 Some(NearbyContainer {
3071 view: c.clone(),
3072 distance_m,
3073 rows,
3074 })
3075 })
3076 .collect();
3077 list.sort_by(|a, b| {
3078 a.distance_m
3079 .partial_cmp(&b.distance_m)
3080 .unwrap_or(std::cmp::Ordering::Equal)
3081 });
3082 list
3083 }
3084
3085 pub fn nearest_placed_container(
3087 &self,
3088 max_dist: f32,
3089 ) -> Option<flatland_protocol::PlacedContainerView> {
3090 let (px, py) = self.player_position();
3091 self.placed_containers
3092 .iter()
3093 .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
3094 .min_by(|a, b| {
3095 let da = (a.x - px).hypot(a.y - py);
3096 let db = (b.x - px).hypot(b.y - py);
3097 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3098 })
3099 .cloned()
3100 }
3101
3102 pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
3105 let filter = self.inventory_filter.as_str();
3106 match self.inventory_tab {
3107 InventoryTab::OnPerson => {
3108 let mut rows = self.worn_rows_filtered(filter);
3109 rows.extend(self.person_rows_filtered(filter));
3110 rows
3111 }
3112 InventoryTab::Nearby => {
3113 let mut rows = Vec::new();
3114 for nc in self.nearby_containers() {
3115 if filter.is_empty() {
3116 rows.extend(nc.rows);
3117 continue;
3118 }
3119 let shell = nc.rows.first().cloned();
3120 let contents: Vec<_> = nc
3121 .rows
3122 .iter()
3123 .skip(1)
3124 .filter(|r| stack_matches_filter(&r.stack, filter))
3125 .cloned()
3126 .collect();
3127 let shell_hit = shell
3128 .as_ref()
3129 .map(|s| stack_matches_filter(&s.stack, filter))
3130 .unwrap_or(false);
3131 if shell_hit || !contents.is_empty() {
3132 if let Some(s) = shell {
3133 rows.push(s);
3134 }
3135 if shell_hit {
3136 rows.extend(nc.rows.into_iter().skip(1));
3137 } else {
3138 rows.extend(contents);
3139 }
3140 }
3141 }
3142 rows
3143 }
3144 }
3145 }
3146
3147 pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
3148 self.inventory_selectable_rows()
3149 .into_iter()
3150 .nth(self.inventory_menu_index)
3151 }
3152
3153 fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
3154 let cat = self
3155 .inventory_item_category(&row.stack.template_id)
3156 .unwrap_or("");
3157 if cat == "key" {
3158 self.key_inventory_label(&row.stack)
3159 } else {
3160 row.stack
3161 .display_name
3162 .clone()
3163 .unwrap_or_else(|| row.stack.template_id.clone())
3164 }
3165 }
3166
3167 fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
3169 let bindings = format_status_bindings_suffix(
3170 &row.stack.status_bindings,
3171 self.tick,
3172 DEFAULT_TICK_HZ,
3173 );
3174 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3175 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3176 let mode = Self::grant_mode(&row.stack);
3177 format!(" [grant {effect} · {mode} — e apply]")
3178 } else {
3179 String::new()
3180 };
3181 let qty = if row.stack.quantity > 1 {
3182 format!(" ×{}", row.stack.quantity)
3183 } else {
3184 String::new()
3185 };
3186 let worn_slot = if row.is_equip_shell {
3187 match row.from {
3188 flatland_protocol::InventoryLocation::Worn { slot } => {
3189 format!(" ({})", body_slot_label(slot))
3190 }
3191 _ => String::new(),
3192 }
3193 } else {
3194 String::new()
3195 };
3196 format!("{grant_hint}{bindings}{qty}{worn_slot}")
3197 }
3198
3199 fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
3200 (
3201 row.stack.template_id.clone(),
3202 self.inventory_row_base_label(row),
3203 self.inventory_row_visible_mod_signature(row),
3204 )
3205 }
3206
3207 fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
3209 let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
3210 for row in self.inventory_selectable_rows() {
3211 if row.stack.item_instance_id.is_none() {
3212 continue;
3213 }
3214 let key = self.inventory_row_instance_identity_key(&row);
3215 *counts.entry(key).or_default() += 1;
3216 }
3217 counts
3218 .into_iter()
3219 .filter(|(_, n)| *n > 1)
3220 .map(|(k, _)| k)
3221 .collect()
3222 }
3223
3224 fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
3225 let hex: String = id
3226 .as_simple()
3227 .to_string()
3228 .chars()
3229 .filter(|c| c.is_ascii_hexdigit())
3230 .collect();
3231 let short = if hex.len() >= 4 {
3232 &hex[hex.len() - 4..]
3233 } else {
3234 hex.as_str()
3235 };
3236 format!("Instance {id} (#{short})")
3237 }
3238
3239 pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
3241 let cat = self
3242 .inventory_item_category(&row.stack.template_id)
3243 .unwrap_or("");
3244 let label = self.inventory_row_base_label(row);
3245 let hint: String = if row.is_equip_shell {
3246 " [worn — Enter to unequip]".into()
3247 } else if row.is_chest_shell {
3248 let (locked, lodging_note) = match &row.from {
3249 flatland_protocol::InventoryLocation::Placed { container_id } => {
3250 let locked = self
3251 .placed_containers
3252 .iter()
3253 .find(|c| c.id == *container_id)
3254 .map(|c| c.locked)
3255 .unwrap_or(false);
3256 let lodging_note = self
3257 .lodging_occupancy_label(container_id)
3258 .map(|who| format!(" [lodging: {who}]"))
3259 .unwrap_or_default();
3260 (locked, lodging_note)
3261 }
3262 _ => (false, String::new()),
3263 };
3264 if locked {
3265 format!(" [locked — Enter pick up · l unlock]{lodging_note}")
3266 } else {
3267 format!(" [Enter pick up · l lock]{lodging_note}")
3268 }
3269 } else if cat == "key" {
3270 self.key_inventory_hint(&row.stack)
3271 } else {
3272 match cat {
3273 "weapon" => " [weapon]".into(),
3274 "container" => " [bag/chest/belt]".into(),
3275 "lodging" => " [worker lodging]".into(),
3276 "armor" => " [armor]".into(),
3277 _ => String::new(),
3278 }
3279 };
3280 let qty = if row.stack.quantity > 1 {
3281 format!(" ×{}", row.stack.quantity)
3282 } else {
3283 String::new()
3284 };
3285 let bindings = format_status_bindings_suffix(
3286 &row.stack.status_bindings,
3287 self.tick,
3288 DEFAULT_TICK_HZ,
3289 );
3290 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3291 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3292 let mode = Self::grant_mode(&row.stack);
3293 format!(" [grant {effect} · {mode} — e apply]")
3294 } else {
3295 String::new()
3296 };
3297 let mass = self.stack_mass(&row.stack);
3298 let mass_kg = (mass >= 0.05).then_some(mass);
3299 let mass_str = mass_kg
3300 .map(|m| format!(" {m:.1} kg"))
3301 .unwrap_or_default();
3302 let volume = self.container_volume_stats(row);
3303 let vol_str = self.container_volume_label(row);
3304
3305 let mut title = label.clone();
3306 title.push_str(&qty);
3307 if row.is_equip_shell {
3308 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3309 title.push_str(&format!(" ({})", body_slot_label(slot)));
3310 }
3311 }
3312
3313 InventoryRowView {
3314 depth: row.depth,
3315 text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
3316 title: format!("{title}{grant_hint}{bindings}"),
3317 mass_kg,
3318 volume,
3319 instance_tooltip: None,
3320 }
3321 }
3322
3323 fn push_browser_item(
3324 &self,
3325 lines: &mut Vec<InventoryBrowserLine>,
3326 row: &InventoryRow,
3327 global_idx: &mut usize,
3328 target: usize,
3329 highlight: bool,
3330 ambiguous_instance_keys: &HashSet<(String, String, String)>,
3331 ) {
3332 let mut view = self.format_inventory_row(row);
3333 if let Some(id) = row.stack.item_instance_id {
3334 let key = self.inventory_row_instance_identity_key(row);
3335 if ambiguous_instance_keys.contains(&key) {
3336 view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
3337 }
3338 }
3339 lines.push(InventoryBrowserLine::Item {
3340 selectable_index: *global_idx,
3341 selected: highlight && *global_idx == target,
3342 depth: view.depth,
3343 text: view.text,
3344 title: view.title,
3345 mass_kg: view.mass_kg,
3346 volume: view.volume,
3347 instance_tooltip: view.instance_tooltip,
3348 });
3349 *global_idx += 1;
3350 }
3351
3352 pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
3355 let mut lines = Vec::new();
3356 let target = self.inventory_menu_index;
3357 let highlight = !self.show_move_picker && !self.show_grant_picker;
3358 let filter = self.inventory_filter.as_str();
3359 let mut global_idx = 0usize;
3360 let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
3361
3362 match self.inventory_tab {
3363 InventoryTab::OnPerson => {
3364 lines.push(InventoryBrowserLine::Section("— Worn —".into()));
3365 let worn = self.worn_rows_filtered(filter);
3366 if worn.is_empty() {
3367 lines.push(InventoryBrowserLine::Hint(
3368 " (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
3369 ));
3370 } else {
3371 for row in &worn {
3372 if row.is_equip_shell {
3373 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3374 lines.push(InventoryBrowserLine::SlotLabel(format!(
3375 " {}:",
3376 body_slot_label(slot)
3377 )));
3378 }
3379 }
3380 self.push_browser_item(
3381 &mut lines,
3382 row,
3383 &mut global_idx,
3384 target,
3385 highlight,
3386 &ambiguous_instance_keys,
3387 );
3388 }
3389 }
3390
3391 lines.push(InventoryBrowserLine::Blank);
3392 lines.push(InventoryBrowserLine::Section(
3393 "— On you (loose, not worn) —".into(),
3394 ));
3395 let person = self.person_rows_filtered(filter);
3396 if person.is_empty() {
3397 lines.push(InventoryBrowserLine::Hint(" (empty)".into()));
3398 } else {
3399 let mut last_group: Option<&'static str> = None;
3400 for row in &person {
3401 if row.depth == 0 {
3402 let cat = row
3403 .stack
3404 .category
3405 .as_deref()
3406 .or_else(|| self.inventory_item_category(&row.stack.template_id))
3407 .unwrap_or("");
3408 let (group, _) = inventory_category_group(cat);
3409 if last_group != Some(group) {
3410 lines.push(InventoryBrowserLine::SlotLabel(format!(
3411 " {group}"
3412 )));
3413 last_group = Some(group);
3414 }
3415 }
3416 self.push_browser_item(
3417 &mut lines,
3418 row,
3419 &mut global_idx,
3420 target,
3421 highlight,
3422 &ambiguous_instance_keys,
3423 );
3424 }
3425 }
3426 }
3427 InventoryTab::Nearby => {
3428 let nearby = self.nearby_containers();
3429 if nearby.is_empty() {
3430 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3431 lines.push(InventoryBrowserLine::Hint(
3432 " (none within reach — walk up to a chest)".into(),
3433 ));
3434 lines.push(InventoryBrowserLine::Hint(
3435 " Select an on-person item, then m / Enter → move into chest.".into(),
3436 ));
3437 } else {
3438 let mut any_visible = false;
3439 for nc in &nearby {
3440 let shell = nc.rows.first();
3441 let contents: Vec<&InventoryRow> = if filter.is_empty() {
3442 nc.rows.iter().skip(1).collect()
3443 } else {
3444 let shell_hit = shell
3445 .map(|s| {
3446 let f = filter.to_ascii_lowercase();
3447 let name = s
3448 .stack
3449 .display_name
3450 .as_deref()
3451 .unwrap_or("")
3452 .to_ascii_lowercase();
3453 let tid = s.stack.template_id.to_ascii_lowercase();
3454 name.contains(&f) || tid.contains(&f)
3455 })
3456 .unwrap_or(false);
3457 if shell_hit {
3458 nc.rows.iter().skip(1).collect()
3459 } else {
3460 nc.rows
3461 .iter()
3462 .skip(1)
3463 .filter(|r| stack_matches_filter(&r.stack, filter))
3464 .collect()
3465 }
3466 };
3467 let shell_visible = filter.is_empty()
3468 || shell
3469 .map(|s| stack_matches_filter(&s.stack, filter))
3470 .unwrap_or(false)
3471 || !contents.is_empty();
3472 if !shell_visible && shell.is_some() {
3473 continue;
3474 }
3475 any_visible = true;
3476 lines.push(InventoryBrowserLine::Blank);
3477 let lock_note = if nc.view.locked && nc.view.accessible {
3478 " unlocked with your key"
3479 } else if nc.view.locked {
3480 " locked"
3481 } else {
3482 ""
3483 };
3484 lines.push(InventoryBrowserLine::Section(format!(
3485 "— {} ({:.0}m away){lock_note} —",
3486 nc.view.display_name, nc.distance_m
3487 )));
3488 if !nc.view.accessible {
3489 lines.push(InventoryBrowserLine::Hint(
3490 " locked — need the matching key (l to try)".into(),
3491 ));
3492 } else if nc.rows.is_empty() {
3493 lines.push(InventoryBrowserLine::Hint(
3494 " (empty — switch to On person, select an item, m to move in)"
3495 .into(),
3496 ));
3497 } else if let Some(shell_row) = shell {
3498 self.push_browser_item(
3499 &mut lines,
3500 shell_row,
3501 &mut global_idx,
3502 target,
3503 highlight,
3504 &ambiguous_instance_keys,
3505 );
3506 for row in contents {
3507 self.push_browser_item(
3508 &mut lines,
3509 row,
3510 &mut global_idx,
3511 target,
3512 highlight,
3513 &ambiguous_instance_keys,
3514 );
3515 }
3516 }
3517 }
3518 if !any_visible {
3519 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3520 lines.push(InventoryBrowserLine::Hint(
3521 " (no matching items — clear filter with Esc)".into(),
3522 ));
3523 }
3524 }
3525 }
3526 }
3527 lines
3528 }
3529
3530 pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
3532 let mut opts = Vec::new();
3533 opts.push(MoveOption {
3534 label: "Relocate…".into(),
3535 kind: MoveOptionKind::RelocatePlaced {
3536 container_id: container_id.to_string(),
3537 },
3538 });
3539 opts.push(MoveOption {
3540 label: "On your person (loose)".into(),
3541 kind: MoveOptionKind::PickupPlaced {
3542 container_id: container_id.to_string(),
3543 nest_location: flatland_protocol::InventoryLocation::Root,
3544 nest_parent_instance_id: None,
3545 },
3546 });
3547 for (slot, item) in &self.worn {
3548 if item.category.as_deref() != Some("container") {
3549 continue;
3550 }
3551 if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
3552 continue;
3553 }
3554 let Some(parent_id) = item.item_instance_id else {
3555 continue;
3556 };
3557 let shell_name = item
3558 .display_name
3559 .clone()
3560 .unwrap_or_else(|| item.template_id.clone());
3561 opts.push(MoveOption {
3562 label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
3563 kind: MoveOptionKind::PickupPlaced {
3564 container_id: container_id.to_string(),
3565 nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
3566 nest_parent_instance_id: Some(parent_id),
3567 },
3568 });
3569 Self::append_chest_pickup_nested(
3571 &mut opts,
3572 container_id,
3573 flatland_protocol::InventoryLocation::Worn { slot: *slot },
3574 item,
3575 &format!("in {shell_name}"),
3576 );
3577 }
3578 opts.push(MoveOption {
3579 label: "Cancel".into(),
3580 kind: MoveOptionKind::Cancel,
3581 });
3582 opts
3583 }
3584
3585 fn append_chest_pickup_nested(
3586 opts: &mut Vec<MoveOption>,
3587 container_id: &str,
3588 location: flatland_protocol::InventoryLocation,
3589 parent: &flatland_protocol::ItemStack,
3590 context: &str,
3591 ) {
3592 for child in &parent.contents {
3593 if child.category.as_deref() != Some("container") {
3594 continue;
3595 }
3596 if !Self::is_volume_container_stack(child) {
3597 continue;
3598 }
3599 if child.world_placeable == Some(true) {
3601 continue;
3602 }
3603 let Some(child_id) = child.item_instance_id else {
3604 continue;
3605 };
3606 let name = child
3607 .display_name
3608 .clone()
3609 .unwrap_or_else(|| child.template_id.clone());
3610 opts.push(MoveOption {
3611 label: format!("{name} ({context})"),
3612 kind: MoveOptionKind::PickupPlaced {
3613 container_id: container_id.to_string(),
3614 nest_location: location.clone(),
3615 nest_parent_instance_id: Some(child_id),
3616 },
3617 });
3618 Self::append_chest_pickup_nested(
3619 opts,
3620 container_id,
3621 location.clone(),
3622 child,
3623 &format!("in {name}"),
3624 );
3625 }
3626 }
3627
3628 pub fn move_destinations_for(
3630 &self,
3631 from: &flatland_protocol::InventoryLocation,
3632 from_parent_instance_id: Option<uuid::Uuid>,
3633 moving_instance_id: Option<uuid::Uuid>,
3634 moving_template_id: &str,
3635 ) -> Vec<MoveOption> {
3636 let mut opts = Vec::new();
3637 if *from != flatland_protocol::InventoryLocation::Root {
3638 opts.push(MoveOption {
3639 label: "On your person (loose)".into(),
3640 kind: MoveOptionKind::Move {
3641 location: flatland_protocol::InventoryLocation::Root,
3642 parent_instance_id: None,
3643 },
3644 });
3645 }
3646 for (slot, item) in &self.worn {
3647 if item.category.as_deref() != Some("container") {
3648 continue;
3649 }
3650 let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3651 let shell_name = item
3652 .display_name
3653 .clone()
3654 .unwrap_or_else(|| item.template_id.clone());
3655
3656 if *slot != BodySlot::Waist
3658 && item.item_instance_id != moving_instance_id
3659 && Self::is_volume_container_stack(item)
3660 {
3661 Self::push_move_destination(
3662 &mut opts,
3663 format!("{shell_name} (worn {})", body_slot_label(*slot)),
3664 location.clone(),
3665 item.item_instance_id,
3666 from,
3667 from_parent_instance_id,
3668 );
3669 }
3670
3671 if *slot == BodySlot::Waist
3673 && Self::attaches_to_belt_loop(moving_template_id)
3674 && item.item_instance_id != moving_instance_id
3675 {
3676 Self::push_move_destination(
3677 &mut opts,
3678 format!("{shell_name} (belt loop)"),
3679 location.clone(),
3680 item.item_instance_id,
3681 from,
3682 from_parent_instance_id,
3683 );
3684 }
3685
3686 let context = if *slot == BodySlot::Waist {
3687 format!("on {shell_name}")
3688 } else {
3689 format!("in {shell_name}")
3690 };
3691 Self::append_nested_container_destinations(
3692 &mut opts,
3693 location,
3694 item,
3695 &context,
3696 from,
3697 from_parent_instance_id,
3698 moving_instance_id,
3699 );
3700 }
3701 for nc in self.nearby_containers() {
3702 if !nc.view.accessible {
3703 continue;
3704 }
3705 let location = flatland_protocol::InventoryLocation::Placed {
3706 container_id: nc.view.id.clone(),
3707 };
3708 Self::push_move_destination(
3709 &mut opts,
3710 format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
3711 location,
3712 nc.view.item_instance_id,
3713 from,
3714 from_parent_instance_id,
3715 );
3716 }
3717 let allow_drop = moving_instance_id
3718 .and_then(|id| self.stack_for_instance(id))
3719 .map(|stack| {
3720 !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
3721 })
3722 .unwrap_or(
3723 moving_template_id != KEY_TEMPLATE && moving_template_id != PROPERTY_DEED_TEMPLATE,
3724 );
3725 if allow_drop {
3726 opts.push(MoveOption {
3727 label: "Drop on the ground".into(),
3728 kind: MoveOptionKind::Drop,
3729 });
3730 }
3731 opts.push(MoveOption {
3732 label: "Cancel".into(),
3733 kind: MoveOptionKind::Cancel,
3734 });
3735 opts
3736 }
3737
3738 fn is_same_container_dest(
3739 dest_location: &flatland_protocol::InventoryLocation,
3740 dest_parent: Option<uuid::Uuid>,
3741 from: &flatland_protocol::InventoryLocation,
3742 from_parent: Option<uuid::Uuid>,
3743 ) -> bool {
3744 dest_location == from && dest_parent == from_parent
3745 }
3746
3747 fn push_move_destination(
3748 opts: &mut Vec<MoveOption>,
3749 label: String,
3750 location: flatland_protocol::InventoryLocation,
3751 parent_instance_id: Option<uuid::Uuid>,
3752 from: &flatland_protocol::InventoryLocation,
3753 from_parent_instance_id: Option<uuid::Uuid>,
3754 ) {
3755 if Self::is_same_container_dest(
3756 &location,
3757 parent_instance_id,
3758 from,
3759 from_parent_instance_id,
3760 ) {
3761 return;
3762 }
3763 opts.push(MoveOption {
3764 label,
3765 kind: MoveOptionKind::Move {
3766 location,
3767 parent_instance_id,
3768 },
3769 });
3770 }
3771
3772 fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
3773 stack.capacity_volume.is_some_and(|c| c > 0.0)
3774 }
3775
3776 fn attaches_to_belt_loop(template_id: &str) -> bool {
3777 matches!(template_id, "leather_pouch" | "dimensional_pouch")
3778 }
3779
3780 fn append_nested_container_destinations(
3781 opts: &mut Vec<MoveOption>,
3782 location: flatland_protocol::InventoryLocation,
3783 container: &flatland_protocol::ItemStack,
3784 context: &str,
3785 from: &flatland_protocol::InventoryLocation,
3786 from_parent_instance_id: Option<uuid::Uuid>,
3787 moving_instance_id: Option<uuid::Uuid>,
3788 ) {
3789 for child in &container.contents {
3790 if Self::is_volume_container_stack(child)
3791 && child.item_instance_id != moving_instance_id
3792 {
3793 let name = child
3794 .display_name
3795 .clone()
3796 .unwrap_or_else(|| child.template_id.clone());
3797 Self::push_move_destination(
3798 opts,
3799 format!("{name} ({context})"),
3800 location.clone(),
3801 child.item_instance_id,
3802 from,
3803 from_parent_instance_id,
3804 );
3805 }
3806 let nested_context = format!(
3807 "in {}",
3808 child.display_name.as_deref().unwrap_or(&child.template_id)
3809 );
3810 Self::append_nested_container_destinations(
3811 opts,
3812 location.clone(),
3813 child,
3814 &nested_context,
3815 from,
3816 from_parent_instance_id,
3817 moving_instance_id,
3818 );
3819 }
3820 }
3821
3822 fn clamp_inventory_indices(&mut self) {
3823 let n = self.inventory_selectable_rows().len();
3824 self.inventory_menu_index = if n == 0 {
3825 0
3826 } else {
3827 self.inventory_menu_index.min(n - 1)
3828 };
3829 if let Some(picker) = &self.move_picker {
3830 let pn = picker.options.len();
3831 self.move_picker_index = if pn == 0 {
3832 0
3833 } else {
3834 self.move_picker_index.min(pn - 1)
3835 };
3836 }
3837 }
3838
3839 fn sync_interior_map_context(&mut self) {
3844 if self.effective_inside_building().is_none() {
3845 self.interior_map = None;
3846 if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
3847 self.z_platforms = platforms;
3848 self.z_transitions = transitions;
3849 }
3850 return;
3851 }
3852 self.sync_interior_z_bands();
3853 }
3854
3855 fn sync_interior_z_bands(&mut self) {
3857 if self.effective_inside_building().is_some() {
3858 if let Some(map) = &self.interior_map {
3859 if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
3860 if self.z_bands_outdoor_backup.is_none() {
3861 self.z_bands_outdoor_backup = Some((
3862 std::mem::take(&mut self.z_platforms),
3863 std::mem::take(&mut self.z_transitions),
3864 ));
3865 }
3866 self.z_platforms = map.z_platforms.clone();
3867 self.z_transitions = map.z_transitions.clone();
3868 }
3869 }
3870 }
3871 }
3872
3873 fn apply_snapshot_fields(
3874 &mut self,
3875 snapshot: &flatland_protocol::Snapshot,
3876 entity_id: EntityId,
3877 ) {
3878 self.tick = snapshot.tick;
3879 self.chunk_rev = snapshot.chunk_rev;
3880 self.content_rev = snapshot.content_rev;
3881 self.publish_rev = snapshot.publish_rev;
3882 self.resource_nodes = snapshot.resource_nodes.clone();
3883 self.ground_drops = snapshot.ground_drops.clone();
3884 self.placed_containers = snapshot.placed_containers.clone();
3885 self.world_x0 = snapshot.world_x0;
3886 self.world_y0 = snapshot.world_y0;
3887 self.world_width_m = snapshot.world_width_m;
3888 self.world_height_m = snapshot.world_height_m;
3889 self.world_clock = snapshot.world_clock;
3890 self.terrain_zones = snapshot.terrain_zones.clone();
3891 self.z_platforms = snapshot.z_platforms.clone();
3892 self.z_transitions = snapshot.z_transitions.clone();
3893 self.z_bands_outdoor_backup = None;
3895 self.buildings = snapshot.buildings.clone();
3896 self.doors = snapshot.doors.clone();
3897 self.interior_map = snapshot.interior_map.clone();
3898 self.npcs = snapshot.npcs.clone();
3899 self.blueprints = snapshot.blueprints.clone();
3900 self.sync_inventory_from_stacks(&snapshot.inventory);
3901 self.player = snapshot
3902 .entities
3903 .iter()
3904 .find(|e| e.id == entity_id)
3905 .cloned();
3906 self.entities = snapshot.entities.clone();
3907 self.quest_log = snapshot.quest_log.clone();
3908 self.apply_hired_workers(snapshot.hired_workers.clone());
3909 self.interactables = snapshot.interactables.clone();
3910 self.ledger = snapshot.ledger.clone();
3911 self.career = snapshot.career.clone();
3912 self.combat_fx = snapshot.combat_fx.clone();
3913 self.property_zones = snapshot.property_zones.clone();
3914 self.tax_zones = snapshot.tax_zones.clone();
3915 self.growth_zones = snapshot.growth_zones.clone();
3916 self.biome_zones = snapshot.biome_zones.clone();
3917 self.property_plots = snapshot.property_plots.clone();
3918 self.property_plot_settings = snapshot.property_plot_settings.clone();
3919 if self.effective_inside_building().is_some() {
3922 self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
3923 }
3924 self.sync_interior_map_context();
3925 self.refresh_whisper_range();
3926 }
3927
3928 fn refresh_inventory_ui(&mut self) {
3932 if let Some(picker) = &self.move_picker {
3933 let instance_id = picker.item_instance_id;
3934 let still_exists = self
3935 .inventory_selectable_rows()
3936 .iter()
3937 .any(|r| r.stack.item_instance_id == Some(instance_id));
3938 if !still_exists {
3939 self.move_picker = None;
3940 self.show_move_picker = false;
3941 }
3942 }
3943 if let Some(picker) = &self.destroy_picker {
3944 let instance_id = picker.item_instance_id;
3945 let still_exists = self
3946 .inventory_selectable_rows()
3947 .iter()
3948 .any(|r| r.stack.item_instance_id == Some(instance_id));
3949 if !still_exists {
3950 self.destroy_picker = None;
3951 self.show_destroy_picker = false;
3952 self.destroy_confirm_pending = false;
3953 }
3954 }
3955 self.clamp_inventory_indices();
3956 }
3957
3958 fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
3964 let selected_id = self
3965 .hired_workers
3966 .get(self.workers_menu_index)
3967 .map(|w| w.instance_id.clone());
3968 workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
3969 let now = Instant::now();
3970 for w in &workers {
3971 let prev_err = self
3972 .hired_workers
3973 .iter()
3974 .find(|p| p.instance_id == w.instance_id)
3975 .and_then(|p| p.last_error.as_deref());
3976 let new_err = w.last_error.as_deref();
3977 if new_err != prev_err {
3978 if let Some(err) = new_err {
3979 if !worker_error_is_transient(err) {
3980 self.push_log(format!("Worker {}: {err}", w.label));
3981 }
3982 }
3983 }
3984 }
3985 let mut next_display = BTreeMap::new();
3986 let mut next_errors = BTreeMap::new();
3987 for w in &workers {
3988 let mut sticky = self
3989 .worker_step_display
3990 .remove(&w.instance_id)
3991 .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
3992 sticky.observe(&w.step_label, now);
3993 next_display.insert(w.instance_id.clone(), sticky);
3994
3995 let mut err_sticky = self
3996 .worker_error_display
3997 .remove(&w.instance_id)
3998 .unwrap_or_default();
3999 err_sticky.observe(w.last_error.as_deref(), now);
4000 if err_sticky.shown(now).is_some() {
4001 next_errors.insert(w.instance_id.clone(), err_sticky);
4002 }
4003 }
4004 self.worker_step_display = next_display;
4005 self.worker_error_display = next_errors;
4006 self.hired_workers = workers;
4007 self.sync_worker_take_picker_from_hired();
4008 if let Some(id) = selected_id {
4009 if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
4010 self.workers_menu_index = idx;
4011 return;
4012 }
4013 }
4014 if self.workers_menu_index >= self.hired_workers.len() {
4015 self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
4016 }
4017 }
4018
4019 fn sync_worker_take_picker_from_hired(&mut self) {
4021 if !self.show_worker_take_picker {
4022 return;
4023 }
4024 let Some(picker) = self.worker_take_picker.clone() else {
4025 return;
4026 };
4027 let Some(worker) = self
4028 .hired_workers
4029 .iter()
4030 .find(|w| w.instance_id == picker.worker_instance_id)
4031 .cloned()
4032 else {
4033 self.show_worker_take_picker = false;
4034 self.worker_take_picker = None;
4035 self.worker_take_picker_index = 0;
4036 return;
4037 };
4038 let options: Vec<WorkerGiveOption> = worker
4039 .inventory
4040 .iter()
4041 .filter_map(|stack| {
4042 let item_instance_id = stack.item_instance_id?;
4043 let label = stack
4044 .display_name
4045 .clone()
4046 .unwrap_or_else(|| stack.template_id.clone());
4047 let label = if stack.quantity > 1 {
4048 format!("{label} ×{}", stack.quantity)
4049 } else {
4050 label
4051 };
4052 Some(WorkerGiveOption {
4053 item_instance_id,
4054 label,
4055 quantity: stack.quantity,
4056 template_id: stack.template_id.clone(),
4057 })
4058 })
4059 .collect();
4060 if options.is_empty() {
4061 self.show_worker_take_picker = false;
4062 self.worker_take_picker = None;
4063 self.worker_take_picker_index = 0;
4064 return;
4065 }
4066 let prev_id = picker
4067 .options
4068 .get(self.worker_take_picker_index)
4069 .map(|o| o.item_instance_id);
4070 let idx = prev_id
4071 .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
4072 .unwrap_or(0)
4073 .min(options.len().saturating_sub(1));
4074 let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
4075 let quantity = picker.quantity.clamp(1, max_qty);
4076 self.worker_take_picker_index = idx;
4077 self.worker_take_picker = Some(WorkerTakePicker {
4078 worker_instance_id: picker.worker_instance_id,
4079 worker_label: picker.worker_label,
4080 options,
4081 quantity,
4082 });
4083 }
4084
4085 pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
4087 self.worker_step_display
4088 .get(worker_instance_id)
4089 .map(|s| s.shown.as_str())
4090 .or_else(|| {
4091 self.hired_workers
4092 .iter()
4093 .find(|w| w.instance_id == worker_instance_id)
4094 .map(|w| w.step_label.as_str())
4095 })
4096 .unwrap_or("")
4097 }
4098
4099 pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
4101 let now = Instant::now();
4102 self.worker_error_display
4103 .get(worker_instance_id)
4104 .and_then(|s| s.shown(now))
4105 .or_else(|| {
4106 self.hired_workers
4107 .iter()
4108 .find(|w| w.instance_id == worker_instance_id)
4109 .and_then(|w| w.last_error.as_deref())
4110 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
4111 })
4112 .filter(|e| !worker_error_is_hud_noise(e))
4113 }
4114
4115 fn apply_combat_hud(&mut self, combat: &CombatHud) {
4116 self.in_combat = combat.in_combat;
4117 self.auto_attack = combat.auto_attack;
4118 self.combat_has_los = combat.has_los;
4119 self.attack_cd_ticks = combat.attack_cd_ticks;
4120 self.gcd_ticks = combat.gcd_ticks;
4121 self.weapon_ability_id = combat.ability_id.clone();
4122 self.mainhand_template_id = combat.mainhand_template_id.clone();
4123 self.mainhand_label = combat.mainhand_label.clone();
4124 self.offhand_template_id = combat.offhand_template_id.clone();
4125 self.offhand_label = combat.offhand_label.clone();
4126 self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
4127 1
4128 } else {
4129 combat.mainhand_hand_slots
4130 };
4131 self.defense = combat.defense.clone();
4132 self.worn = combat.worn.iter().cloned().collect();
4133 self.carry_mass = combat.carry_mass;
4134 self.carry_mass_max = combat.carry_mass_max;
4135 self.encumbrance = combat.encumbrance;
4136 self.cast_progress = combat.cast.clone();
4137 self.timed_channel = combat.timed_channel.clone();
4138 self.ability_cooldowns = combat.ability_cooldowns.clone();
4139 self.blocking_active = combat.blocking_active;
4140 self.max_target_slots = combat.max_target_slots.max(1);
4141 self.combat_slots = combat.slots.clone();
4142 self.rotation_presets = combat.rotation_presets.clone();
4143 self.known_abilities = combat.known_abilities.clone();
4144 self.ability_meta = combat
4145 .ability_meta
4146 .iter()
4147 .cloned()
4148 .map(|meta| (meta.id.clone(), meta))
4149 .collect();
4150 self.ability_mastery = combat
4151 .ability_mastery
4152 .iter()
4153 .cloned()
4154 .map(|row| (row.ability_id.clone(), row))
4155 .collect();
4156 self.hotbar = combat.hotbar.clone();
4157 self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
4158 self.keychain_stacks = combat.keychain.clone();
4159 self.whisper_pouch_stacks = combat.whisper_pouch.clone();
4160 self.combat_target_detail = combat.target.clone();
4161 self.statuses = combat.statuses.clone();
4162 self.combat_target = combat.target_entity_id;
4163 if combat.progression_xp_base > 0.0 {
4164 self.progression_curve = Some(flatland_protocol::ProgressionCurve {
4165 baseline_display: combat.progression_baseline,
4166 xp_base: combat.progression_xp_base,
4167 xp_growth: combat.progression_xp_growth,
4168 });
4169 }
4170 if let Some(xp) = &combat.progression_xp {
4171 if let Some(player) = &mut self.player {
4172 player.progression_xp = Some(xp.clone());
4173 if let Some(attrs) = combat.attributes {
4174 player.attributes = Some(attrs);
4175 }
4176 if let Some(skills) = &combat.skills {
4177 player.skills = Some(skills.clone());
4178 }
4179 }
4180 }
4181 if let Some(label) = &combat.target_label {
4182 self.combat_target_label = Some(label.clone());
4183 } else if let Some(id) = combat.target_entity_id {
4184 self.combat_target_label = self
4185 .entities
4186 .iter()
4187 .find(|e| e.id == id)
4188 .map(|e| e.label.clone())
4189 .or_else(|| self.combat_target_label.clone());
4190 }
4191 self.refresh_inventory_ui();
4192 }
4193
4194 pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
4196 self.combat_slots
4197 .iter()
4198 .find(|s| s.slot_index == slot)
4199 .and_then(|s| s.target_entity_id)
4200 .or_else(|| if slot == 1 { self.combat_target } else { None })
4201 }
4202
4203 pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
4205 self.ability_meta
4206 .get(ability_id)
4207 .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
4208 .unwrap_or(self.ground_target.is_some())
4211 }
4212
4213 pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
4215 self.ability_meta
4216 .get(ability_id)
4217 .map(|meta| meta.aim_mode == "ground")
4218 .unwrap_or(false)
4219 }
4220
4221 pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
4224 self.ability_meta
4225 .get(ability_id)
4226 .map(|meta| meta.auto_rotation_eligible)
4227 .unwrap_or(true)
4228 }
4229
4230 pub fn set_ground_target(&mut self, x: f32, y: f32) {
4232 self.ground_target = Some((x, y, 0.0));
4233 }
4234
4235 pub fn clear_ground_target(&mut self) {
4237 self.ground_target = None;
4238 }
4239
4240 pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
4243 if !(1..=9).contains(&slot_1_to_9) {
4244 return None;
4245 }
4246 self.hotbar
4247 .get((slot_1_to_9 - 1) as usize)
4248 .and_then(|a| a.as_deref())
4249 .filter(|id| !id.is_empty())
4250 }
4251
4252 pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
4254 let binding = self.hotbar_ability(slot_1_to_9)?;
4255 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
4256 let name = self
4257 .inventory_hints
4258 .get(template_id)
4259 .map(|h| h.display_name.as_str())
4260 .unwrap_or(template_id);
4261 let qty = self.inventory.get(template_id).copied().unwrap_or(0);
4262 Some(format!("{name}×{qty}"))
4263 } else {
4264 Some(binding.to_string())
4265 }
4266 }
4267
4268 pub fn loadout_ability_choices(&self) -> Vec<String> {
4270 let mut out = self.known_abilities.clone();
4271 let weapon = self.weapon_ability_id.trim();
4272 if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
4273 out.push(weapon.to_string());
4274 }
4275 out
4276 }
4277
4278 pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
4280 let mut out = Vec::new();
4281 for ability in self.loadout_ability_choices() {
4282 let meta = if ability == self.weapon_ability_id {
4283 Some("weapon".into())
4284 } else {
4285 None
4286 };
4287 out.push(LoadoutHotbarChoice {
4288 binding: ability.clone(),
4289 label: ability,
4290 meta,
4291 });
4292 }
4293 let mut consumables: Vec<(String, String, u32)> = Vec::new();
4294 for stack in &self.inventory_stacks {
4295 if Self::stack_is_item_grant(stack) {
4296 continue;
4297 }
4298 if self.inventory_item_category(&stack.template_id) != Some("consumable") {
4299 continue;
4300 }
4301 let qty = stack.quantity.max(1);
4302 if let Some((_, _, existing)) = consumables
4303 .iter_mut()
4304 .find(|(id, _, _)| id == &stack.template_id)
4305 {
4306 *existing = existing.saturating_add(qty);
4307 } else {
4308 let label = stack
4309 .display_name
4310 .clone()
4311 .or_else(|| {
4312 self.inventory_hints
4313 .get(&stack.template_id)
4314 .map(|h| h.display_name.clone())
4315 })
4316 .unwrap_or_else(|| stack.template_id.clone());
4317 consumables.push((stack.template_id.clone(), label, qty));
4318 }
4319 }
4320 consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
4321 for (template_id, label, qty) in consumables {
4322 out.push(LoadoutHotbarChoice {
4323 binding: flatland_protocol::hotbar_consumable_binding(&template_id),
4324 label: format!("{label} ×{qty}"),
4325 meta: Some("use".into()),
4326 });
4327 }
4328 out
4329 }
4330
4331 pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
4333 self.combat_candidates()
4334 }
4335
4336 pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
4338 let (px, py) = self.player_position();
4339 let dist = |id: EntityId| {
4340 self.entities
4341 .iter()
4342 .find(|e| e.id == id)
4343 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4344 .unwrap_or(f32::MAX)
4345 };
4346
4347 let mut allies = Vec::new();
4348 if let Some(me) = self.player.as_ref() {
4350 let alive = me
4351 .vitals
4352 .as_ref()
4353 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4354 .unwrap_or(true);
4355 if alive {
4356 allies.push((self.entity_id, "Yourself".into()));
4357 }
4358 }
4359 for entity in &self.entities {
4360 if entity.id == self.entity_id {
4361 continue;
4362 }
4363 if entity.vitals.is_some() {
4364 let alive = entity
4365 .vitals
4366 .as_ref()
4367 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4368 .unwrap_or(true);
4369 if alive {
4370 allies.push((entity.id, entity.label.clone()));
4371 }
4372 }
4373 }
4374 allies.sort_by(|(a, _), (b, _)| {
4375 if *a == self.entity_id {
4376 return std::cmp::Ordering::Less;
4377 }
4378 if *b == self.entity_id {
4379 return std::cmp::Ordering::Greater;
4380 }
4381 dist(*a)
4382 .partial_cmp(&dist(*b))
4383 .unwrap_or(std::cmp::Ordering::Equal)
4384 });
4385
4386 let mut monsters = self.combat_candidates();
4387 monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
4388 allies.into_iter().chain(monsters).collect()
4389 }
4390
4391 fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
4392 match slot_index {
4393 2 => self.t2_candidates(),
4394 _ => self.t1_candidates(),
4395 }
4396 }
4397
4398 pub fn pick_combat_target_at(
4400 &self,
4401 wx: f32,
4402 wy: f32,
4403 slot_index: u8,
4404 radius_m: f32,
4405 ) -> Option<(EntityId, String)> {
4406 let mut best: Option<(f32, EntityId, String)> = None;
4407 for (id, label) in self.candidates_for_slot(slot_index) {
4408 let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
4409 if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
4411 let d = distance(wx, wy, npc.x, npc.y);
4412 if d <= radius_m {
4413 best = match best {
4414 Some((bd, _, _)) if bd <= d => best,
4415 _ => Some((d, id, label)),
4416 };
4417 }
4418 }
4419 continue;
4420 };
4421 let d = distance(
4422 wx,
4423 wy,
4424 entity.transform.position.x,
4425 entity.transform.position.y,
4426 );
4427 if d <= radius_m {
4428 best = match best {
4429 Some((bd, _, _)) if bd <= d => best,
4430 _ => Some((d, id, label)),
4431 };
4432 }
4433 }
4434 best.map(|(_, id, label)| (id, label))
4435 }
4436
4437 pub(crate) fn restore_from_welcome(
4439 &mut self,
4440 session_id: SessionId,
4441 entity_id: EntityId,
4442 snapshot: &flatland_protocol::Snapshot,
4443 ) {
4444 self.clear_harvest_state();
4445 self.disconnect_reason = None;
4446 self.show_stats = false;
4447 self.show_craft_menu = false;
4448 self.show_shop_menu = false;
4449 self.shop_catalog = None;
4450 self.show_inventory_menu = false;
4451 self.session_id = session_id;
4452 self.entity_id = entity_id;
4453 self.connected = true;
4454 self.apply_snapshot_fields(snapshot, entity_id);
4455 if let Some(combat) = &snapshot.combat {
4456 self.apply_combat_hud(combat);
4457 let stacks = self.inventory_stacks.clone();
4458 self.sync_inventory_from_stacks(&stacks);
4459 }
4460 }
4461
4462 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
4463 self.tick = delta.tick;
4464 self.world_clock = delta.world_clock;
4465
4466 if delta.entities.is_empty() {
4468 self.ground_drops = delta.ground_drops.clone();
4469 self.combat_fx = delta.combat_fx.clone();
4470 self.property_plots = delta.property_plots.clone();
4471 self.apply_terrain_overlays(&delta.terrain_overlays);
4472 if let Some(combat) = &delta.combat {
4473 self.apply_combat_hud(combat);
4474 let stacks = self.inventory_stacks.clone();
4475 self.sync_inventory_from_stacks(&stacks);
4476 }
4477 self.refresh_whisper_range();
4479 return;
4480 }
4481 if !delta.buildings.is_empty() {
4482 self.buildings = delta.buildings.clone();
4483 }
4484 if !delta.blueprints.is_empty() {
4485 self.blueprints = delta.blueprints.clone();
4486 }
4487 self.sync_inventory_from_stacks(&delta.inventory);
4488
4489 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
4490 self.player = Some(updated.clone());
4491 }
4492 self.entities = delta.entities.clone();
4493 if self.player.is_none() {
4494 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
4495 }
4496
4497 self.sync_interior_map_context();
4498
4499 if !delta.resource_nodes.is_empty() {
4503 self.resource_nodes = delta.resource_nodes.clone();
4504 } else if delta.interior_map.is_some()
4505 || self.effective_inside_building().is_some()
4506 {
4507 self.resource_nodes = delta.resource_nodes.clone();
4508 }
4509 self.ground_drops = delta.ground_drops.clone();
4510 if self
4511 .player
4512 .as_ref()
4513 .is_none_or(|p| p.inside_building.is_none())
4514 {
4515 self.placed_containers = delta.placed_containers.clone();
4516 }
4517 if !delta.doors.is_empty() {
4518 self.doors = delta.doors.clone();
4519 }
4520 if self.effective_inside_building().is_some() {
4521 if let Some(map) = &delta.interior_map {
4522 self.interior_map = Some(map.clone());
4523 }
4524 } else {
4525 self.interior_map = None;
4526 }
4527 self.sync_interior_z_bands();
4528 self.npcs = delta.npcs.clone();
4530 if !delta.quest_log.is_empty() {
4531 self.quest_log = delta.quest_log.clone();
4532 }
4533 self.apply_hired_workers(delta.hired_workers.clone());
4534 if !delta.interactables.is_empty() {
4535 self.interactables = delta.interactables.clone();
4536 }
4537 if delta.ledger.is_some() {
4538 self.ledger = delta.ledger.clone();
4539 }
4540 if delta.career.is_some() {
4541 self.career = delta.career.clone();
4542 }
4543 self.combat_fx = delta.combat_fx.clone();
4544 if !delta.property_plots.is_empty() {
4546 self.property_plots = delta.property_plots.clone();
4547 }
4548 self.apply_terrain_overlays(&delta.terrain_overlays);
4549 if let Some(combat) = &delta.combat {
4550 self.apply_combat_hud(combat);
4551 let stacks = self.inventory_stacks.clone();
4552 self.sync_inventory_from_stacks(&stacks);
4553 } else {
4554 self.refresh_inventory_ui();
4555 }
4556 self.refresh_whisper_range();
4557 }
4558
4559 fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
4562 self.terrain_zones
4563 .retain(|z| !z.id.starts_with("rt:"));
4564 self.terrain_zones.extend(overlays.iter().cloned());
4565 }
4566
4567 fn refresh_whisper_range(&mut self) {
4570 let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
4571 return;
4572 };
4573 let (px, py) = self.player_position();
4574 let in_range = self.entities.iter().any(|e| {
4575 e.id == peer
4576 && distance(
4577 px,
4578 py,
4579 e.transform.position.x,
4580 e.transform.position.y,
4581 ) <= INTERACTION_RADIUS_M
4582 });
4583 if !in_range {
4584 self.social_chat.cancel_whisper_out_of_range();
4585 }
4586 }
4587
4588 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
4590 let (px, py) = self.player_position();
4591 let mut out = Vec::new();
4592 for npc in &self.npcs {
4593 let Some(eid) = npc.entity_id else {
4594 continue;
4595 };
4596 let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
4597 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
4598 if alive && has_hp {
4599 out.push((eid, npc.label.clone()));
4600 }
4601 }
4602 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
4603 let dist = |id: EntityId| {
4604 self.entities
4605 .iter()
4606 .find(|e| e.id == id)
4607 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4608 .unwrap_or(f32::MAX)
4609 };
4610 dist(*a_id)
4611 .partial_cmp(&dist(*b_id))
4612 .unwrap_or(std::cmp::Ordering::Equal)
4613 .then_with(|| a_label.cmp(b_label))
4614 .then_with(|| a_id.cmp(b_id))
4615 });
4616 out
4617 }
4618
4619 pub fn refresh_combat_target_label(&mut self) {
4620 let Some(id) = self.combat_target else {
4621 return;
4622 };
4623 if let Some((_, label)) = self
4624 .combat_candidates()
4625 .into_iter()
4626 .find(|(eid, _)| *eid == id)
4627 {
4628 self.combat_target_label = Some(label);
4629 } else if let Some(label) = self
4630 .entities
4631 .iter()
4632 .find(|e| e.id == id)
4633 .map(|e| e.label.clone())
4634 {
4635 self.combat_target_label = Some(label);
4636 }
4637 }
4638
4639 pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
4640 self.quest_log
4641 .iter()
4642 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
4643 .collect()
4644 }
4645
4646 pub fn has_worker_lodging(&self) -> bool {
4648 self.free_worker_lodging_slots() > 0
4649 }
4650
4651 pub fn free_worker_lodging_slots(&self) -> i64 {
4653 let slots: u32 = self
4654 .placed_containers
4655 .iter()
4656 .filter(|c| match (self.character_id, c.owner_character_id) {
4657 (Some(me), Some(owner)) => me == owner,
4658 (Some(_), None) => false,
4659 (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
4660 })
4661 .map(|c| c.worker_lodging_capacity.unwrap_or(0))
4662 .sum();
4663 let used = self.hired_workers.len() as u32;
4664 slots as i64 - used as i64
4665 }
4666
4667 pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
4669 let mut names: Vec<String> = self
4670 .hired_workers
4671 .iter()
4672 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
4673 .map(|w| w.label.clone())
4674 .collect();
4675 names.sort();
4676 names
4677 }
4678
4679 pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
4681 let is_lodging = self
4682 .placed_containers
4683 .iter()
4684 .find(|c| c.id == container_id)
4685 .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
4686 if !is_lodging {
4687 return None;
4688 }
4689 let names = self.lodging_occupant_labels(container_id);
4690 Some(if names.is_empty() {
4691 "vacant".into()
4692 } else {
4693 names.join(", ")
4694 })
4695 }
4696
4697 pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
4698 self.quest_log
4699 .iter()
4700 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
4701 .or_else(|| {
4702 self.quest_log
4703 .iter()
4704 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
4705 })
4706 }
4707
4708 pub fn nearest_interact_target(&self) -> Option<String> {
4710 let (px, py) = self.player_position();
4711 let inside = self.effective_inside_building();
4712
4713 #[derive(Clone, Copy, PartialEq, Eq)]
4714 enum Kind {
4715 Player,
4716 Npc,
4717 HiredWorker,
4718 QuestBoard,
4719 ExitDoor,
4720 EnterDoor,
4721 Well,
4722 Water,
4723 }
4724
4725 fn kind_priority(kind: Kind) -> u8 {
4726 match kind {
4727 Kind::Player => 0,
4728 Kind::Npc => 0,
4729 Kind::HiredWorker => 0,
4730 Kind::QuestBoard => 1,
4731 Kind::ExitDoor => 2,
4732 Kind::EnterDoor => 3,
4733 Kind::Well => 4,
4734 Kind::Water => 5,
4735 }
4736 }
4737
4738 let mut best: Option<(f32, Kind, String)> = None;
4739
4740 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
4741 if dist > max {
4742 return;
4743 }
4744 let replace = match best {
4745 None => true,
4746 Some((bd, _bk, _)) if dist < bd - 0.05 => true,
4747 Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
4748 kind_priority(kind) < kind_priority(bk)
4749 }
4750 _ => false,
4751 };
4752 if replace {
4753 best = Some((dist, kind, id));
4754 }
4755 };
4756
4757 for npc in &self.npcs {
4758 consider(
4759 distance(px, py, npc.x, npc.y),
4760 INTERACTION_RADIUS_M,
4761 Kind::Npc,
4762 npc.id.clone(),
4763 );
4764 }
4765
4766 for worker in &self.hired_workers {
4767 consider(
4768 distance(px, py, worker.x, worker.y),
4769 INTERACTION_RADIUS_M,
4770 Kind::HiredWorker,
4771 worker.instance_id.clone(),
4772 );
4773 }
4774
4775 for entity in &self.entities {
4776 if entity.id == self.entity_id || entity.vitals.is_none() || entity.label.trim().is_empty()
4777 {
4778 continue;
4779 }
4780 if self
4782 .hired_workers
4783 .iter()
4784 .any(|w| w.entity_id == entity.id)
4785 {
4786 continue;
4787 }
4788 consider(
4789 distance(
4790 px,
4791 py,
4792 entity.transform.position.x,
4793 entity.transform.position.y,
4794 ),
4795 INTERACTION_RADIUS_M,
4796 Kind::Player,
4797 entity.id.to_string(),
4798 );
4799 }
4800
4801 for door in &self.doors {
4802 if let Some(ref bid) = inside {
4803 if door.building_id != *bid {
4804 continue;
4805 }
4806 let is_exit = door.portal.is_some();
4807 let max = if is_exit {
4808 INTERACTION_RADIUS_M
4809 } else {
4810 DOOR_INTERACTION_RADIUS_M
4811 };
4812 let kind = if is_exit {
4813 Kind::ExitDoor
4814 } else {
4815 Kind::EnterDoor
4816 };
4817 consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
4818 continue;
4819 }
4820 consider(
4821 distance(px, py, door.x, door.y),
4822 DOOR_INTERACTION_RADIUS_M,
4823 Kind::EnterDoor,
4824 door.id.clone(),
4825 );
4826 }
4827
4828 if inside.is_none() {
4829 for inter in &self.interactables {
4830 if inter.kind == "quest_board" {
4831 consider(
4832 distance(px, py, inter.x, inter.y),
4833 QUEST_BOARD_INTERACTION_RADIUS_M,
4834 Kind::QuestBoard,
4835 inter.id.clone(),
4836 );
4837 }
4838 }
4839 for building in &self.buildings {
4840 if !building.tags.iter().any(|t| t == "well") {
4841 continue;
4842 }
4843 consider(
4844 distance(px, py, building.x, building.y),
4845 INTERACTION_RADIUS_M,
4846 Kind::Well,
4847 building.id.clone(),
4848 );
4849 }
4850 if self.in_shallow_water() {
4851 consider(
4852 0.0,
4853 INTERACTION_RADIUS_M,
4854 Kind::Water,
4855 "water_source".into(),
4856 );
4857 }
4858 }
4859
4860 best.map(|(_, _, id)| id)
4861 }
4862
4863 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
4865 if self.effective_inside_building().is_some() {
4866 return None;
4867 }
4868 let (px, py) = self.player_position();
4869 self.interactables
4870 .iter()
4871 .filter(|i| i.kind == "quest_board")
4872 .map(|i| {
4873 let label = if i.label.is_empty() {
4874 "Quest board".to_string()
4875 } else {
4876 i.label.clone()
4877 };
4878 (label, distance(px, py, i.x, i.y))
4879 })
4880 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
4881 }
4882
4883 pub fn template_display_name(&self, template_id: &str) -> String {
4885 self.inventory_hints
4886 .get(template_id)
4887 .map(|h| h.display_name.clone())
4888 .filter(|n| !n.is_empty())
4889 .unwrap_or_else(|| humanize_template_id(template_id))
4890 }
4891
4892 pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
4894 use crate::worker_route_editor::{
4895 node_candidates, node_candidates_stable, route_editor_lodging_anchor,
4896 };
4897 let lodging = self
4898 .worker_route_editor
4899 .as_ref()
4900 .and_then(|ed| ed.lodging_container_id.as_deref());
4901 match route_editor_lodging_anchor(lodging, &self.placed_containers) {
4902 Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
4903 None => node_candidates_stable(&self.resource_nodes),
4904 }
4905 }
4906
4907 pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
4908 if dist_m.is_nan() {
4909 return "—".into();
4910 }
4911 let from_bed = self
4912 .worker_route_editor
4913 .as_ref()
4914 .and_then(|ed| ed.lodging_container_id.as_deref())
4915 .and_then(|id| {
4916 self.placed_containers
4917 .iter()
4918 .find(|c| c.id == id)
4919 .map(|c| c.display_name.clone())
4920 });
4921 match from_bed {
4922 Some(bed) => format!("{dist_m:.0}m from {bed}"),
4923 None => format!("{dist_m:.0}m"),
4924 }
4925 }
4926
4927 pub fn placed_container_public_label(
4929 &self,
4930 c: &flatland_protocol::PlacedContainerView,
4931 ) -> String {
4932 let is_owner = match (self.character_id, c.owner_character_id) {
4933 (Some(me), Some(owner)) => me == owner,
4934 _ => false,
4935 };
4936 if is_owner {
4937 c.display_name.clone()
4938 } else {
4939 self.template_display_name(&c.template_id)
4940 }
4941 }
4942
4943 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
4945 let mut out = Vec::new();
4946 for stack in &self.inventory_stacks {
4947 if stack.template_id == KEY_TEMPLATE {
4948 out.push(KeychainEntry {
4949 stack: stack.clone(),
4950 stowed: false,
4951 });
4952 }
4953 }
4954 for stack in &self.keychain_stacks {
4955 if stack.template_id == KEY_TEMPLATE {
4956 out.push(KeychainEntry {
4957 stack: stack.clone(),
4958 stowed: true,
4959 });
4960 }
4961 }
4962 out
4963 }
4964
4965 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
4967 if stack.template_id != KEY_TEMPLATE {
4968 return None;
4969 }
4970 if let Some(name) = stack
4971 .props
4972 .get(PROP_OPENS_CONTAINER_NAME)
4973 .filter(|n| !n.is_empty())
4974 {
4975 return Some(name.clone());
4976 }
4977 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
4978 self.container_name_for_lock_id(opens)
4979 }
4980
4981 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
4983 if stack.template_id == KEY_TEMPLATE {
4984 self.template_display_name(KEY_TEMPLATE)
4985 } else {
4986 stack
4987 .display_name
4988 .clone()
4989 .unwrap_or_else(|| stack.template_id.clone())
4990 }
4991 }
4992
4993 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
4995 if stack.template_id != KEY_TEMPLATE {
4996 return String::new();
4997 }
4998 match self.key_pair_chest_label(stack) {
4999 Some(chest) if self.key_drop_blocked(stack) => {
5000 format!(" [key for {chest} — can't drop while locked]")
5001 }
5002 Some(chest) => format!(" [key for {chest}]"),
5003 None => " [key — unpaired]".into(),
5004 }
5005 }
5006
5007 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
5009 for c in &self.placed_containers {
5010 if c.lock_id.as_deref() == Some(lock) {
5011 return Some(c.display_name.clone());
5012 }
5013 }
5014 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
5015 self.worn
5016 .values()
5017 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
5018 })
5019 }
5020
5021 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
5023 if stack.template_id != KEY_TEMPLATE {
5024 return false;
5025 }
5026 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
5027 return false;
5028 };
5029 for c in &self.placed_containers {
5030 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
5031 return true;
5032 }
5033 }
5034 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
5035 return true;
5036 }
5037 self.worn
5038 .values()
5039 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
5040 }
5041
5042 pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
5044 stack.template_id == PROPERTY_DEED_TEMPLATE
5045 }
5046
5047 pub fn is_property_deed_template(template_id: &str) -> bool {
5048 template_id == PROPERTY_DEED_TEMPLATE
5049 }
5050
5051 pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
5052 stack
5053 .props
5054 .get("plot_id")
5055 .and_then(|s| uuid::Uuid::parse_str(s).ok())
5056 }
5057
5058 pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
5060 let (px, py) = self.player_position();
5061 let (cx, cy) = self.farm_plot_cell_under_player()?;
5062 let tx = cx as f32 + 0.5;
5063 let ty = cy as f32 + 0.5;
5064 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5065 return None;
5066 }
5067 let kind = self
5068 .terrain_at(tx, ty)
5069 .or_else(|| self.terrain_at(px, py));
5070 if kind == Some(TerrainKindView::Tilled) {
5071 return None;
5072 }
5073 if matches!(
5074 kind,
5075 Some(TerrainKindView::ShallowWater)
5076 | Some(TerrainKindView::DeepWater)
5077 | Some(TerrainKindView::Rock)
5078 ) {
5079 return None;
5080 }
5081 Some((tx, ty))
5082 }
5083
5084 fn container_name_in_stacks(
5085 stacks: &[flatland_protocol::ItemStack],
5086 lock: &str,
5087 ) -> Option<String> {
5088 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
5089 for s in stacks {
5090 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
5091 return Some(GameState::stack_container_label(s));
5092 }
5093 if let Some(name) = walk(&s.contents, lock) {
5094 return Some(name);
5095 }
5096 }
5097 None
5098 }
5099 walk(stacks, lock)
5100 }
5101
5102 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
5103 stack
5104 .props
5105 .get(PROP_CUSTOM_NAME)
5106 .cloned()
5107 .or_else(|| stack.display_name.clone())
5108 .unwrap_or_else(|| stack.template_id.clone())
5109 }
5110
5111 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5112 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5113 for s in stacks {
5114 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
5115 return true;
5116 }
5117 if walk(&s.contents, lock) {
5118 return true;
5119 }
5120 }
5121 false
5122 }
5123 walk(stacks, lock)
5124 }
5125
5126 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
5127 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
5128 return Some(stack.clone());
5129 }
5130 for worn in self.worn.values() {
5131 if worn.item_instance_id == Some(instance_id) {
5132 return Some(worn.clone());
5133 }
5134 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
5135 return Some(stack.clone());
5136 }
5137 }
5138 None
5139 }
5140
5141 pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
5143 self.property_zones
5144 .iter()
5145 .enumerate()
5146 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5147 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5148 .map(|(_, z)| z)
5149 }
5150
5151 pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
5153 self.tax_zones
5154 .iter()
5155 .enumerate()
5156 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5157 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5158 .map(|(_, z)| z)
5159 }
5160
5161 pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
5163 let mut max_bps = 0u32;
5164 let mut y = y0 + 0.5;
5165 while y < y1 {
5166 let mut x = x0 + 0.5;
5167 while x < x1 {
5168 if let Some(tz) = self.tax_zone_at(x, y) {
5169 max_bps = max_bps.max(tz.rate_bps);
5170 }
5171 x += 1.0;
5172 }
5173 y += 1.0;
5174 }
5175 max_bps
5176 }
5177
5178 pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5180 let mode = self.claim_mode.as_ref()?;
5181 let w = mode.width_m.max(1) as f32;
5182 let h = mode.height_m.max(1) as f32;
5183 Some((mode.anchor_x, mode.anchor_y, mode.anchor_x + w, mode.anchor_y + h))
5184 }
5185
5186 pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5188 let mode = self.relocate_mode.as_ref()?;
5189 let x0 = mode.cursor_x.floor();
5190 let y0 = mode.cursor_y.floor();
5191 Some((x0, y0, x0 + 1.0, y0 + 1.0))
5192 }
5193
5194 pub fn claim_quote(
5197 &self,
5198 ) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
5199 let mode = self.claim_mode.as_ref()?;
5200 let zone = self
5201 .property_zones
5202 .iter()
5203 .find(|z| z.id == mode.zone_id)?;
5204 let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
5205 let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
5206 let zone_area = zone_view_area_m2(zone).max(1.0);
5207 let area_frac = (area / zone_area).clamp(0.0, 1.0);
5208 let weight = self
5209 .property_plot_settings
5210 .as_ref()
5211 .map(|s| s.tax_premium_weight)
5212 .unwrap_or(0.5)
5213 .max(0.0);
5214 let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
5215 let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
5216 let purchase = ((zone.crown_price_copper as f64)
5217 * (area_frac as f64)
5218 * (premium as f64))
5219 .ceil()
5220 .max(0.0) as u64;
5221 let upkeep = if zone.upkeep_copper_per_day == 0 {
5222 0
5223 } else {
5224 ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
5225 .ceil()
5226 .max(1.0) as u64
5227 };
5228 let copper = crate::currency::copper_from_counts(&self.inventory);
5229 let can_afford = copper >= purchase;
5230 let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
5231 Some((purchase, upkeep, area, premium, can_afford, valid, reason))
5232 }
5233
5234 fn validate_claim_footprint(
5235 &self,
5236 zone: &flatland_protocol::PropertyZoneView,
5237 x0: f32,
5238 y0: f32,
5239 x1: f32,
5240 y1: f32,
5241 area: f32,
5242 ) -> (bool, String) {
5243 let min_area = self
5244 .property_plot_settings
5245 .as_ref()
5246 .map(|s| s.min_plot_area_m2)
5247 .unwrap_or(4.0);
5248 if area + f32::EPSILON < min_area {
5249 return (false, "plot too small".into());
5250 }
5251 if zone.max_area_m2.is_some_and(|m| area > m) {
5252 return (false, "plot exceeds max area".into());
5253 }
5254 if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
5255 return (false, "plot must lie inside the property zone".into());
5256 }
5257 if self.property_plots.iter().any(|p| {
5258 rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1)
5259 }) {
5260 return (false, "plot overlaps an existing claim".into());
5261 }
5262 (true, String::new())
5263 }
5264
5265 pub fn free_property_zone_under_player(
5267 &self,
5268 ) -> Option<&flatland_protocol::PropertyZoneView> {
5269 let (px, py) = self.player_position();
5270 let zone = self.property_zone_at(px, py)?;
5271 if self
5272 .property_plots
5273 .iter()
5274 .any(|p| point_in_plot(px, py, p))
5275 {
5276 return None;
5277 }
5278 Some(zone)
5279 }
5280
5281 pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5283 let (px, py) = self.player_position();
5284 self.property_plots
5285 .iter()
5286 .find(|p| p.is_mine && point_in_plot(px, py, p))
5287 }
5288
5289 pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5291 let (px, py) = self.player_position();
5292 self.property_plots
5293 .iter()
5294 .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
5295 }
5296
5297 pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
5299 if self.farmable_plot_under_player().is_none() {
5300 return None;
5301 }
5302 let (px, py) = self.player_position();
5303 Some((px.floor() as i32, py.floor() as i32))
5304 }
5305
5306 fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
5307 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5308 self.resource_nodes.iter().any(|n| {
5309 let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
5310 ncx == cx && ncy == cy
5311 || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
5312 })
5313 }
5314
5315 fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
5316 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5317 let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
5318 || self
5319 .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
5320 .is_some_and(|z| z.kind == TerrainKindView::Tilled);
5321 if !tilled {
5322 return false;
5323 }
5324 !self.resource_node_occupies_farm_cell(cx, cy)
5325 }
5326
5327 pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
5329 let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
5330 return false;
5331 };
5332 self.free_tilled_plant_slot_at(cx, cy)
5333 }
5334
5335 pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
5337 let (px, py) = self.player_position();
5338 for dy in -2..=2 {
5339 for dx in -2..=2 {
5340 let cx = px.floor() as i32 + dx;
5341 let cy = py.floor() as i32 + dy;
5342 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5343 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5344 continue;
5345 }
5346 if self.free_tilled_plant_slot_at(cx, cy) {
5347 return true;
5348 }
5349 }
5350 }
5351 false
5352 }
5353
5354 fn stack_is_farm_seed(stack: &flatland_protocol::ItemStack) -> bool {
5355 stack.quantity > 0
5356 && (stack.props.contains_key("seed_for")
5357 || stack.template_id.ends_with("_seed")
5358 || stack.template_id == "potato_seed"
5359 || stack.template_id == "carrot_seed")
5360 }
5361
5362 pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
5364 let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
5365 fn walk(
5366 stacks: &[flatland_protocol::ItemStack],
5367 counts: &mut std::collections::HashMap<String, u32>,
5368 ) {
5369 for s in stacks {
5370 if GameState::stack_is_farm_seed(s) {
5371 *counts.entry(s.template_id.clone()).or_default() += s.quantity;
5372 }
5373 walk(&s.contents, counts);
5374 }
5375 }
5376 walk(&self.inventory_stacks, &mut counts);
5377 for worn in self.worn.values() {
5378 walk(std::slice::from_ref(worn), &mut counts);
5379 }
5380 let mut out: Vec<_> = counts
5381 .into_iter()
5382 .map(|(template_id, quantity)| {
5383 let label = self
5384 .inventory_hints
5385 .get(&template_id)
5386 .map(|h| h.display_name.clone())
5387 .filter(|n| !n.trim().is_empty())
5388 .unwrap_or_else(|| humanize_template_id(&template_id));
5389 (template_id, quantity, label)
5390 })
5391 .collect();
5392 out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
5393 out
5394 }
5395
5396 pub fn first_farm_seed_template(&self) -> Option<String> {
5398 self.farm_seed_entries()
5399 .into_iter()
5400 .next()
5401 .map(|(id, _, _)| id)
5402 }
5403
5404 pub fn clamp_plant_menu(&mut self) {
5405 let n = self.farm_seed_entries().len();
5406 if n == 0 {
5407 self.plant_menu_index = 0;
5408 self.plant_quantity = 1;
5409 return;
5410 }
5411 self.plant_menu_index = self.plant_menu_index.min(n - 1);
5412 let max_qty = self
5413 .farm_seed_entries()
5414 .get(self.plant_menu_index)
5415 .map(|(_, q, _)| *q)
5416 .unwrap_or(1)
5417 .max(1);
5418 self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
5419 }
5420
5421 pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
5422 let entries = self.farm_seed_entries();
5423 let (id, max, label) = entries.get(self.plant_menu_index)?;
5424 let qty = self.plant_quantity.min(*max).max(1);
5425 Some((id.clone(), qty, label.clone()))
5426 }
5427
5428 pub fn location_context_lines(&self) -> Vec<ContextLine> {
5430 let (px, py) = self.player_position();
5431 let inside = self.effective_inside_building();
5432 let mut lines = Vec::new();
5433
5434 if let Some(kind) = self.terrain_at(px, py) {
5435 lines.push(ContextLine {
5436 on_top: true,
5437 text: format!("Terrain: {}", terrain_kind_label(kind)),
5438 });
5439 }
5440
5441 if let Some(id) = inside.as_ref() {
5442 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
5443 lines.push(ContextLine {
5444 on_top: true,
5445 text: format!("Inside: {}", b.label),
5446 });
5447 }
5448 }
5449
5450 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
5451
5452 for node in &self.resource_nodes {
5453 if node.id.starts_with("preview:") {
5454 continue;
5455 }
5456 let dist = distance(px, py, node.x, node.y);
5457 if dist > NEARBY_SCAN_M {
5458 continue;
5459 }
5460 let on_top = dist <= ON_TOP_RADIUS_M;
5461 let prefix = if on_top { "On" } else { "Near" };
5462 let name = resource_node_near_display_label(&node.label);
5463 let action = resource_node_near_action_suffix(node);
5464 nearby.push((
5465 dist,
5466 ContextLine {
5467 on_top,
5468 text: format!("{prefix}: {name} ({dist:.1}m){action}"),
5469 },
5470 ));
5471 }
5472
5473 for drop in &self.ground_drops {
5474 let dist = distance(px, py, drop.x, drop.y);
5475 if dist > INTERACTION_RADIUS_M {
5476 continue;
5477 }
5478 let on_top = dist <= ON_TOP_RADIUS_M;
5479 let name = self.template_display_name(&drop.template_id);
5480 let prefix = if on_top { "On" } else { "Near" };
5481 let qty = if drop.quantity > 1 {
5482 format!(" ×{}", drop.quantity)
5483 } else {
5484 String::new()
5485 };
5486 nearby.push((
5487 dist,
5488 ContextLine {
5489 on_top,
5490 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
5491 },
5492 ));
5493 }
5494
5495 for c in &self.placed_containers {
5496 let dist = distance(px, py, c.x, c.y);
5497 if dist > CONTAINER_RANGE_M {
5498 continue;
5499 }
5500 let on_top = dist <= ON_TOP_RADIUS_M;
5501 let name = self.placed_container_public_label(c);
5502 let lock = if c.locked { " [locked]" } else { "" };
5503 let prefix = if on_top { "On" } else { "Near" };
5504 nearby.push((
5505 dist,
5506 ContextLine {
5507 on_top,
5508 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
5509 },
5510 ));
5511 }
5512
5513 for npc in &self.npcs {
5514 let dist = distance(px, py, npc.x, npc.y);
5515 if dist > NEARBY_SCAN_M {
5516 continue;
5517 }
5518 let on_top = dist <= ON_TOP_RADIUS_M;
5519 let prefix = if on_top { "On" } else { "Near" };
5520 nearby.push((
5521 dist,
5522 ContextLine {
5523 on_top,
5524 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
5525 },
5526 ));
5527 }
5528
5529 for door in &self.doors {
5530 let dist = distance(px, py, door.x, door.y);
5531 if dist > DOOR_INTERACTION_RADIUS_M {
5532 continue;
5533 }
5534 let building = self
5535 .buildings
5536 .iter()
5537 .find(|b| b.id == door.building_id)
5538 .map(|b| b.label.as_str())
5539 .unwrap_or(door.building_id.as_str());
5540 let action = if inside.is_some() && door.portal.is_some() {
5541 "exit"
5542 } else {
5543 "enter"
5544 };
5545 nearby.push((
5546 dist,
5547 ContextLine {
5548 on_top: dist <= ON_TOP_RADIUS_M,
5549 text: format!("{building} door ({dist:.1}m) — f {action}"),
5550 },
5551 ));
5552 }
5553
5554 if inside.is_none() {
5555 for inter in &self.interactables {
5556 if inter.kind != "quest_board" {
5557 continue;
5558 }
5559 let dist = distance(px, py, inter.x, inter.y);
5560 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
5561 continue;
5562 }
5563 let on_top = dist <= ON_TOP_RADIUS_M;
5564 let prefix = if on_top { "On" } else { "Near" };
5565 let label = if inter.label.is_empty() {
5566 "Quest board".to_string()
5567 } else {
5568 inter.label.clone()
5569 };
5570 nearby.push((
5571 dist,
5572 ContextLine {
5573 on_top,
5574 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
5575 },
5576 ));
5577 }
5578 }
5579
5580 if self.in_shallow_water() {
5581 let already = self
5582 .terrain_at(px, py)
5583 .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
5584 if !already {
5585 nearby.push((
5586 0.0,
5587 ContextLine {
5588 on_top: true,
5589 text: "Shallow water — f fill bottle".into(),
5590 },
5591 ));
5592 } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
5593 line.text.push_str(" — f fill bottle");
5594 }
5595 }
5596
5597 if self.claim_mode.is_some() {
5598 nearby.push((
5599 0.0,
5600 ContextLine {
5601 on_top: true,
5602 text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
5603 .into(),
5604 },
5605 ));
5606 } else if let Some(plot) = self.my_plot_under_player() {
5607 let zone = plot
5608 .zone_label
5609 .as_deref()
5610 .filter(|s| !s.trim().is_empty())
5611 .or_else(|| {
5612 self.property_zones
5613 .iter()
5614 .find(|z| z.id == plot.property_zone_id)
5615 .and_then(|z| z.label.as_deref().filter(|s| !s.trim().is_empty()))
5616 })
5617 .unwrap_or(plot.property_zone_id.as_str());
5618 let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
5619 format!("Your plot ({zone}) — f again to sell to crown")
5620 } else {
5621 format!(
5622 "Your plot ({zone}) — c till · p plant · f harvest · o farm access · deed to sell"
5623 )
5624 };
5625 nearby.push((
5626 0.0,
5627 ContextLine {
5628 on_top: true,
5629 text: prompt,
5630 },
5631 ));
5632 } else if let Some(plot) = self.farmable_plot_under_player() {
5633 let owner = plot
5634 .owner_label
5635 .as_deref()
5636 .filter(|s| !s.trim().is_empty())
5637 .unwrap_or("owner");
5638 let disc = if plot.farm_public {
5639 plot.public_tax_discount_bps / 100
5640 } else {
5641 plot.farm_allow
5642 .iter()
5643 .find(|g| Some(g.character_id) == self.character_id)
5644 .map(|g| g.tax_discount_bps / 100)
5645 .unwrap_or(0)
5646 };
5647 nearby.push((
5648 0.0,
5649 ContextLine {
5650 on_top: true,
5651 text: format!(
5652 "Farming permitted — {owner} (tax −{disc}%) — c till · p plant · f harvest"
5653 ),
5654 },
5655 ));
5656 } else if let Some(zone) = self.free_property_zone_under_player() {
5657 let label = zone
5658 .label
5659 .as_deref()
5660 .filter(|s| !s.trim().is_empty())
5661 .unwrap_or(zone.id.as_str());
5662 nearby.push((
5663 0.0,
5664 ContextLine {
5665 on_top: true,
5666 text: format!("Claimable land: {label} — k buy plot"),
5667 },
5668 ));
5669 }
5670
5671 for entity in &self.entities {
5672 if entity.id == self.entity_id {
5673 continue;
5674 }
5675 let dist = distance(
5676 px,
5677 py,
5678 entity.transform.position.x,
5679 entity.transform.position.y,
5680 );
5681 if dist > NEARBY_SCAN_M {
5682 continue;
5683 }
5684 let label = if entity.label.is_empty() {
5685 format!("entity {}", entity.id)
5686 } else {
5687 entity.label.clone()
5688 };
5689 nearby.push((
5690 dist,
5691 ContextLine {
5692 on_top: dist <= ON_TOP_RADIUS_M,
5693 text: format!("Near: {label} ({dist:.1}m)"),
5694 },
5695 ));
5696 }
5697
5698 nearby.sort_by(|a, b| {
5699 a.0.partial_cmp(&b.0)
5700 .unwrap_or(std::cmp::Ordering::Equal)
5701 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
5702 });
5703 lines.extend(nearby.into_iter().map(|(_, l)| l));
5704
5705 if lines.is_empty() {
5706 lines.push(ContextLine {
5707 on_top: false,
5708 text: "(nothing notable nearby)".into(),
5709 });
5710 }
5711
5712 lines
5713 }
5714}
5715
5716#[derive(Debug, Clone)]
5718pub struct ContextLine {
5719 pub on_top: bool,
5720 pub text: String,
5721}
5722
5723const ON_TOP_RADIUS_M: f32 = 0.65;
5724const NEARBY_SCAN_M: f32 = 5.0;
5725
5726pub fn resource_node_near_display_label(label: &str) -> String {
5728 label
5729 .strip_suffix(" (growing)")
5730 .unwrap_or(label)
5731 .to_string()
5732}
5733
5734pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
5736 use flatland_protocol::ResourceNodeState;
5737 if let Some(p) = node.growth_progress {
5738 if p < 1.0 - f32::EPSILON {
5739 let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
5740 return format!(" (growing, {pct}%)");
5741 }
5742 return " — f harvest".to_string();
5743 }
5744 match node.state {
5745 ResourceNodeState::Available => " — f harvest".to_string(),
5746 ResourceNodeState::Harvesting => " (being harvested)".to_string(),
5747 ResourceNodeState::Cooldown => " (depleted)".to_string(),
5748 }
5749}
5750
5751fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
5752 use flatland_protocol::TerrainKindView;
5753 match kind {
5754 TerrainKindView::Grass => "Grass",
5755 TerrainKindView::Dirt => "Dirt",
5756 TerrainKindView::Tilled => "Tilled",
5757 TerrainKindView::Desert => "Desert",
5758 TerrainKindView::Hill => "Hills",
5759 TerrainKindView::Bog => "Bog",
5760 TerrainKindView::Beach => "Beach",
5761 TerrainKindView::ShallowWater => "Shallow water",
5762 TerrainKindView::DeepWater => "Deep water",
5763 TerrainKindView::Trail => "Trail",
5764 TerrainKindView::Road => "Road",
5765 TerrainKindView::Rock => "Rock",
5766 }
5767}
5768
5769fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
5770 crate::world_zones::zone_rects_contain(rects, x, y)
5771}
5772
5773fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
5774 zone.rects
5775 .iter()
5776 .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
5777 .sum()
5778}
5779
5780fn claim_rect_fully_inside_zone(
5781 zone: &flatland_protocol::PropertyZoneView,
5782 x0: f32,
5783 y0: f32,
5784 x1: f32,
5785 y1: f32,
5786) -> bool {
5787 let mut y = y0 + 0.5;
5788 while y < y1 {
5789 let mut x = x0 + 0.5;
5790 while x < x1 {
5791 if !zone_rects_contain(&zone.rects, x, y) {
5792 return false;
5793 }
5794 x += 1.0;
5795 }
5796 y += 1.0;
5797 }
5798 true
5799}
5800
5801fn rects_overlap_half_open(
5802 ax0: f32,
5803 ay0: f32,
5804 ax1: f32,
5805 ay1: f32,
5806 bx0: f32,
5807 by0: f32,
5808 bx1: f32,
5809 by1: f32,
5810) -> bool {
5811 ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
5812}
5813
5814fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
5815 x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
5816}
5817
5818fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
5819 p.zone_label
5820 .as_deref()
5821 .filter(|s| !s.trim().is_empty())
5822 .map(|s| s.to_string())
5823 .unwrap_or_else(|| format!("plot {}", &p.plot_id.to_string()[..8]))
5824}
5825
5826fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
5828 let a = x0.min(x1).floor();
5829 let b = y0.min(y1).floor();
5830 let mut c = x0.max(x1).ceil();
5831 let mut d = y0.max(y1).ceil();
5832 if (c - a) < 1.0 {
5833 c = a + 1.0;
5834 }
5835 if (d - b) < 1.0 {
5836 d = b + 1.0;
5837 }
5838 (a, b, c, d)
5839}
5840
5841fn humanize_template_id(template_id: &str) -> String {
5842 template_id
5843 .split('_')
5844 .map(|word| {
5845 let mut chars = word.chars();
5846 match chars.next() {
5847 None => String::new(),
5848 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
5849 }
5850 })
5851 .collect::<Vec<_>>()
5852 .join(" ")
5853}
5854
5855const HARVEST_RANGE_M: f32 = 1.5;
5857
5858pub struct GameClient<S: PlayConnection> {
5859 session: S,
5860 seq: Seq,
5861 pub state: GameState,
5862 last_move_forward: f32,
5863 last_move_strafe: f32,
5864}
5865
5866impl<S: PlayConnection> GameClient<S> {
5867 pub fn new(session: S) -> Self {
5868 let session_id = session.session_id();
5869 let entity_id = session.entity_id();
5870 let mut client = Self {
5871 session,
5872 seq: 0,
5873 last_move_forward: 0.0,
5874 last_move_strafe: 0.0,
5875 state: GameState {
5876 session_id,
5877 entity_id,
5878 character_id: None,
5879 tick: 0,
5880 chunk_rev: 0,
5881 content_rev: 0,
5882 publish_rev: 0,
5883 entities: Vec::new(),
5884 player: None,
5885 resource_nodes: Vec::new(),
5886 ground_drops: Vec::new(),
5887 placed_containers: Vec::new(),
5888 buildings: Vec::new(),
5889 doors: Vec::new(),
5890 interior_map: None,
5891 npcs: Vec::new(),
5892 blueprints: Vec::new(),
5893 world_x0: 0.0,
5894 world_y0: 0.0,
5895 world_width_m: 0.0,
5896 world_height_m: 0.0,
5897 terrain_zones: Vec::new(),
5898 z_platforms: Vec::new(),
5899 z_transitions: Vec::new(),
5900 z_bands_outdoor_backup: None,
5901 world_clock: flatland_protocol::WorldClock::default(),
5902 inventory: std::collections::HashMap::new(),
5903 inventory_hints: std::collections::HashMap::new(),
5904 logs: VecDeque::new(),
5905 intents_sent: 0,
5906 ticks_received: 0,
5907 connected: false,
5908 disconnect_reason: None,
5909 show_stats: false,
5910 hud_log_hidden: false,
5911 show_equip_menu: false,
5912 equip_menu_index: 0,
5913 show_craft_menu: false,
5914 craft_menu_index: 0,
5915 craft_batch_quantity: 1,
5916 show_shop_menu: false,
5917 shop_catalog: None,
5918 bank_panel: None,
5919 bank_menu_index: 0,
5920 bank_ui_mode: BankUiMode::Menu,
5921 storage_panel: None,
5922 market_panel: None,
5923 market_menu_index: 0,
5924 market_filter: String::new(),
5925 market_filter_focused: false,
5926 market_category_filter: None,
5927 market_buy_confirm: None,
5928 market_ui_mode: MarketUiMode::Browse,
5929 storage_menu_index: 0,
5930 storage_ui_mode: StorageUiMode::Menu,
5931 shop_tab: ShopTab::default(),
5932 shop_menu_index: 0,
5933 shop_quantity: 1,
5934 shop_trade_log: VecDeque::new(),
5935 show_npc_verb_menu: false,
5936 npc_verb_target: None,
5937 npc_verb_index: 0,
5938 player_verbs: crate::social::PlayerVerbState::default(),
5939 social_chat: crate::social::SocialChatState::default(),
5940 trade_ui: crate::social::TradeUiState::default(),
5941 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
5942 show_npc_chat: false,
5943 npc_chat: None,
5944 show_inventory_menu: false,
5945 inventory_menu_index: 0,
5946 inventory_tab: InventoryTab::OnPerson,
5947 inventory_filter: String::new(),
5948 inventory_filter_focused: false,
5949 show_move_picker: false,
5950 show_rename_prompt: false,
5951 show_worker_rename: false,
5952 rename_buffer: String::new(),
5953 move_picker_index: 0,
5954 move_picker: None,
5955 show_grant_picker: false,
5956 grant_picker_index: 0,
5957 grant_picker: None,
5958 show_destroy_picker: false,
5959 destroy_confirm_pending: false,
5960 destroy_picker: None,
5961 combat_target: None,
5962 combat_target_label: None,
5963 ground_target: None,
5964 combat_fx: Vec::new(),
5965 property_zones: Vec::new(),
5966 tax_zones: Vec::new(),
5967 growth_zones: Vec::new(),
5968 biome_zones: Vec::new(),
5969 property_plots: Vec::new(),
5970 property_plot_settings: None,
5971 claim_mode: None,
5972 relocate_mode: None,
5973 sell_plot_confirm: None,
5974 sell_plot_armed_at: None,
5975 show_plant_menu: false,
5976 plant_menu_index: 0,
5977 show_farm_access: false,
5978 farm_access_name_draft: String::new(),
5979 farm_access_discount_bps: 0,
5980 farm_access_index: 0,
5981 plant_quantity: 1,
5982 in_combat: false,
5983 auto_attack: true,
5984 combat_has_los: false,
5985 attack_cd_ticks: 0,
5986 gcd_ticks: 0,
5987 weapon_ability_id: "unarmed".into(),
5988 mainhand_template_id: None,
5989 mainhand_label: None,
5990 offhand_template_id: None,
5991 offhand_label: None,
5992 mainhand_hand_slots: 1,
5993 defense: None,
5994 worn: BTreeMap::new(),
5995 carry_mass: 0.0,
5996 carry_mass_max: 0.0,
5997 encumbrance: flatland_protocol::EncumbranceState::Light,
5998 inventory_stacks: Vec::new(),
5999 keychain_stacks: Vec::new(),
6000 whisper_pouch_stacks: Vec::new(),
6001 combat_target_detail: None,
6002 statuses: Vec::new(),
6003 cast_progress: None,
6004 timed_channel: None,
6005 ability_cooldowns: Vec::new(),
6006 blocking_active: false,
6007 max_target_slots: 1,
6008 combat_slots: Vec::new(),
6009 rotation_presets: Vec::new(),
6010 known_abilities: Vec::new(),
6011 ability_meta: std::collections::HashMap::new(),
6012 ability_mastery: std::collections::HashMap::new(),
6013 hotbar: vec![None; 9],
6014 max_abilities_per_rotation: 0,
6015 show_loadout_menu: false,
6016 show_keychain_menu: false,
6017 keychain_menu_index: 0,
6018 show_rotation_editor: false,
6019 loadout_menu_index: 0,
6020 loadout_hotbar_slot: 1,
6021 loadout_ability_index: 0,
6022 loadout_focus_presets: false,
6023 rotation_editor: RotationEditorState::default(),
6024 harvest_in_progress: false,
6025 harvest_started_at: None,
6026 pending_craft_ack: None,
6027 pending_worker_job_ack: None,
6028 attending_worker_instance_id: None,
6029 quest_log: Vec::new(),
6030 interactables: Vec::new(),
6031 ledger: None,
6032 career: None,
6033 character_sheet_tab: CharacterSheetTab::Character,
6034 ledger_period: LedgerPeriod::Day,
6035 show_quest_offer: false,
6036 pending_quest_offer: None,
6037 show_quest_menu: false,
6038 quest_menu_index: 0,
6039 quest_withdraw_confirm: false,
6040 hired_workers: Vec::new(),
6041 show_workers_menu: false,
6042 workers_menu_index: 0,
6043 workers_menu_compact: false,
6044 worker_step_display: BTreeMap::new(),
6045 worker_error_display: BTreeMap::new(),
6046 show_worker_give_picker: false,
6047 worker_give_picker_index: 0,
6048 worker_give_picker: None,
6049 show_worker_give_target_picker: false,
6050 worker_give_target_picker_index: 0,
6051 worker_give_target_picker: None,
6052 show_worker_take_picker: false,
6053 worker_take_picker_index: 0,
6054 worker_take_picker: None,
6055 show_worker_teach_picker: false,
6056 worker_teach_picker_index: 0,
6057 worker_teach_picker: None,
6058 worker_route_editor: None,
6059 progression_curve: None,
6060 },
6061 };
6062 client.state.apply_client_ui_prefs();
6063 client
6064 }
6065
6066 pub fn entity_id(&self) -> EntityId {
6067 self.state.entity_id
6068 }
6069
6070 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
6071 if self.state.connected {
6072 return Ok(());
6073 }
6074
6075 loop {
6076 match self.session.next_event().await {
6077 Some(SessionEvent::Welcome {
6078 session_id,
6079 entity_id,
6080 snapshot,
6081 }) => {
6082 self.state
6083 .restore_from_welcome(session_id, entity_id, &snapshot);
6084 self.state.apply_client_ui_prefs();
6085 self.state.push_log(format!(
6086 "Connected — session {session_id}, entity {entity_id}"
6087 ));
6088 return Ok(());
6089 }
6090 Some(SessionEvent::Disconnected { .. }) => {
6091 anyhow::bail!("disconnected before welcome");
6092 }
6093 Some(_) => continue,
6094 None => anyhow::bail!("session closed before welcome"),
6095 }
6096 }
6097 }
6098
6099 pub fn drain_events(&mut self) {
6101 while let Some(event) = self.session.try_next_event() {
6102 if self.handle_event_sync(event).is_err() {
6103 break;
6104 }
6105 }
6106 }
6107
6108 pub async fn next_event(&mut self) -> Option<SessionEvent> {
6110 self.session.next_event().await
6111 }
6112
6113 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6114 self.handle_event_sync(event)
6115 }
6116
6117 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6118 match event {
6119 SessionEvent::Welcome {
6120 session_id,
6121 entity_id,
6122 snapshot,
6123 } => {
6124 let resumed = self.state.connected;
6125 self.state
6126 .restore_from_welcome(session_id, entity_id, &snapshot);
6127 if resumed {
6128 self.state.push_log(format!(
6129 "Session restored — session {session_id}, entity {entity_id}"
6130 ));
6131 }
6132 }
6133 SessionEvent::ContentUpdated { snapshot } => {
6134 self.state
6135 .apply_snapshot_fields(&snapshot, self.state.entity_id);
6136 self.state.push_log(format!(
6137 "World updated (content rev {})",
6138 snapshot.content_rev
6139 ));
6140 }
6141 SessionEvent::Tick(delta) => {
6142 self.state.apply_tick_fields(&delta, self.state.entity_id);
6143 self.state.ticks_received += 1;
6144 }
6145 SessionEvent::IntentAck {
6146 entity_id,
6147 seq,
6148 tick,
6149 } => {
6150 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
6151 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
6152 if *craft_seq == seq {
6153 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
6154 if batches > 1 {
6155 self.state.push_log(format!("Crafting {label} ×{batches}…"));
6156 } else {
6157 self.state.push_log(format!("Crafting {label}…"));
6158 }
6159 }
6160 }
6161 if self
6162 .state
6163 .pending_worker_job_ack
6164 .as_ref()
6165 .is_some_and(|p| p.seq == seq)
6166 {
6167 let pending = self.state.pending_worker_job_ack.take().unwrap();
6168 if pending.idle {
6169 self.state.push_log(format!(
6170 "Route cleared for {} — worker idle",
6171 pending.worker_label
6172 ));
6173 } else {
6174 self.state.push_log(format!(
6175 "Route saved for {} — {} stop(s), job loop active",
6176 pending.worker_label, pending.stop_count
6177 ));
6178 }
6179 if self
6180 .state
6181 .worker_route_editor
6182 .as_ref()
6183 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
6184 {
6185 self.close_worker_route_editor();
6186 }
6187 }
6188 }
6189 SessionEvent::Chat(msg) => {
6190 let label = match msg.channel {
6191 flatland_protocol::ChatChannel::Nearby => "nearby",
6192 flatland_protocol::ChatChannel::Direct => "speak",
6193 flatland_protocol::ChatChannel::Whisper => "whisper",
6194 flatland_protocol::ChatChannel::WhisperStone => "stone",
6195 };
6196 let clarity = match msg.clarity {
6197 flatland_protocol::ChatClarity::Clear => "",
6198 flatland_protocol::ChatClarity::Partial => "~",
6199 flatland_protocol::ChatClarity::Heavy => "…",
6200 };
6201 self.state.push_log(format!(
6202 "[{label}{clarity}] {}: {}",
6203 msg.from_name, msg.text
6204 ));
6205 let now_ms = std::time::SystemTime::now()
6206 .duration_since(std::time::UNIX_EPOCH)
6207 .map(|d| d.as_millis() as u64)
6208 .unwrap_or(0);
6209 self.state
6210 .social_chat
6211 .note_speech(&msg, self.state.entity_id, now_ms);
6212 self.state
6213 .social_chat
6214 .push(crate::social::ChatLogEntry::from_message(
6215 msg,
6216 self.state.entity_id,
6217 ));
6218 }
6219 SessionEvent::TradeOpened(panel) => {
6220 self.state.social_chat.pending_trade = None;
6221 let peer = panel.peer_name.clone();
6222 self.state.trade_ui.open(panel);
6223 self.state
6224 .social_chat
6225 .push_system(format!("Trade open with {peer} — p present · r ready · Esc cancel"));
6226 self.state
6227 .social_chat
6228 .push_cue(crate::social::AudioCue::TradeOpened);
6229 }
6230 SessionEvent::TradeClosed { reason } => {
6231 self.state.push_log(reason.clone());
6232 self.state.social_chat.push_system(reason);
6233 self.state.trade_ui.close();
6234 }
6235 SessionEvent::HarvestResult(result) => {
6236 self.state.clear_harvest_state();
6237 crate::harvest_trace!(
6238 entity_id = self.state.entity_id,
6239 node_id = %result.node_id,
6240 template = %result.item_template,
6241 quantity = result.quantity,
6242 client_tick = self.state.tick,
6243 "client applied harvest result"
6244 );
6245 let msg = if result.quantity == 0 {
6246 format!(
6247 "Harvested {} x0 — nothing dropped (loot table rolled empty)",
6248 result.item_template
6249 )
6250 } else {
6251 format!(
6252 "Harvested {} x{} (on the ground — press P to pick up)",
6253 result.item_template, result.quantity
6254 )
6255 };
6256 self.state.push_log(msg);
6257 }
6258 SessionEvent::CraftResult(result) => {
6259 for stack in &result.consumed {
6260 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
6261 *qty = qty.saturating_sub(stack.quantity);
6262 if *qty == 0 {
6263 self.state.inventory.remove(&stack.template_id);
6264 }
6265 }
6266 }
6267 for stack in &result.outputs {
6268 *self
6269 .state
6270 .inventory
6271 .entry(stack.template_id.clone())
6272 .or_insert(0) += stack.quantity;
6273 }
6274 if let Some(output) = result.outputs.first() {
6275 if result.batch_total > 1 {
6276 self.state.push_log(format!(
6277 "Crafted {} x{} ({}/{})",
6278 output.template_id,
6279 output.quantity,
6280 result.batch_index,
6281 result.batch_total
6282 ));
6283 } else {
6284 self.state.push_log(format!(
6285 "Crafted {} x{}",
6286 output.template_id, output.quantity
6287 ));
6288 }
6289 } else {
6290 self.state
6291 .push_log(format!("Craft finished: {}", result.blueprint_id));
6292 }
6293 }
6294 SessionEvent::Death(notice) => {
6295 self.state.clear_harvest_state();
6296 self.state.push_log(notice.message.clone());
6297 self.state.push_log(format!(
6298 "Respawned at ({:.1}, {:.1})",
6299 notice.respawn_x, notice.respawn_y
6300 ));
6301 }
6302 SessionEvent::Interaction(notice) => {
6303 if notice.message.starts_with("Harvest failed:") {
6304 self.state.clear_harvest_state();
6305 }
6306 if notice.message.starts_with("Can't do that:") {
6307 self.state.pending_craft_ack = None;
6308 if let Some(pending) = self.state.pending_worker_job_ack.take() {
6309 if let Some(w) = self
6310 .state
6311 .hired_workers
6312 .iter_mut()
6313 .find(|w| w.instance_id == pending.worker_instance_id)
6314 {
6315 w.route = pending.prev_route;
6316 w.mode = pending.prev_mode;
6317 w.step_label = pending.prev_step_label;
6318 w.last_error = pending.prev_last_error;
6319 }
6320 let reason = notice
6321 .message
6322 .strip_prefix("Can't do that:")
6323 .unwrap_or(¬ice.message)
6324 .trim();
6325 self.state.push_log(format!(
6326 "Route save failed for {}: {reason}",
6327 pending.worker_label
6328 ));
6329 }
6330 let reason = notice
6331 .message
6332 .strip_prefix("Can't do that:")
6333 .unwrap_or(¬ice.message)
6334 .trim();
6335 if reason.contains("already tilled") {
6336 if let Some(plot) = self.state.my_plot_under_player() {
6337 self.state.sell_plot_confirm = Some(plot.plot_id);
6338 self.state.sell_plot_armed_at = Some(Instant::now());
6339 }
6340 }
6341 }
6342 if notice.message.starts_with("Cast failed:") {
6343 self.state.cast_progress = None;
6344 }
6345 if notice.message.contains("slain the") {
6346 self.state.combat_target = None;
6347 self.state.combat_target_label = None;
6348 }
6349 if notice.message.contains("wants to trade") {
6351 if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
6352 let from_name = notice
6353 .message
6354 .split(" wants to trade")
6355 .next()
6356 .unwrap_or("Player")
6357 .to_string();
6358 self.state.social_chat.pending_trade =
6359 Some(crate::social::PendingTradeRequest {
6360 from_entity,
6361 from_name: from_name.clone(),
6362 });
6363 self.state.social_chat.push_system(format!(
6364 "{from_name} wants to trade — [Y] accept · [N] decline"
6365 ));
6366 self.state
6367 .social_chat
6368 .push_cue(crate::social::AudioCue::TradeOffer);
6369 }
6370 }
6371 if notice.message.starts_with("trade request declined") {
6372 self.state
6373 .social_chat
6374 .push_system(notice.message.clone());
6375 self.state
6376 .social_chat
6377 .push_cue(crate::social::AudioCue::TradeDeclined);
6378 }
6379 self.state.apply_interaction_notice(¬ice);
6380 self.state.push_log(notice.message.clone());
6381 }
6382 SessionEvent::ShopOpened(catalog) => {
6383 self.state.apply_shop_catalog(catalog);
6384 }
6385 SessionEvent::BankOpened(panel) => {
6386 self.state.apply_bank_panel(panel);
6387 }
6388 SessionEvent::StorageOpened(panel) => {
6389 self.state.apply_storage_panel(panel);
6390 }
6391 SessionEvent::MarketOpened(panel) => {
6392 self.state.apply_market_panel(panel);
6393 }
6394 SessionEvent::NpcTalkOpened(opened) => {
6395 self.state.show_npc_verb_menu = false;
6396 if self.state.npc_verb_target.is_none() {
6397 self.state.npc_verb_target = Some(opened.npc_id.clone());
6398 }
6399 let label = opened.npc_label.clone();
6400 let banner = if !opened.trade_allowed {
6401 Some("Trade is unavailable right now.".to_string())
6402 } else {
6403 None
6404 };
6405 self.state.show_npc_chat = true;
6406 self.state.npc_chat = Some(NpcChatState {
6407 npc_id: opened.npc_id,
6408 npc_label: opened.npc_label,
6409 lines: if opened.greeting.is_empty() {
6410 vec![]
6411 } else {
6412 vec![format!("{label}: {}", opened.greeting)]
6413 },
6414 input: String::new(),
6415 pending: opened.greeting.is_empty(),
6416 talk_depth: opened.talk_depth,
6417 trade_allowed: opened.trade_allowed,
6418 banner,
6419 });
6420 }
6421 SessionEvent::NpcTalkPending(_) => {
6422 if let Some(chat) = self.state.npc_chat.as_mut() {
6423 chat.pending = true;
6424 }
6425 }
6426 SessionEvent::NpcTalkReply(reply) => {
6427 if let Some(chat) = self.state.npc_chat.as_mut() {
6428 if chat.npc_id == reply.npc_id {
6429 chat.pending = false;
6430 if reply.trade_disabled {
6431 chat.trade_allowed = false;
6432 chat.banner = Some("Trade is unavailable right now.".to_string());
6433 }
6434 if reply.wind_down {
6435 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
6436 if chat.banner.is_none() {
6437 chat.banner =
6438 Some("They're wrapping up — keep it brief.".to_string());
6439 }
6440 }
6441 chat.lines
6442 .push(format!("{}: {}", chat.npc_label, reply.line));
6443 }
6444 }
6445 }
6446 SessionEvent::NpcTalkClosed(closed) => {
6447 if self
6448 .state
6449 .npc_chat
6450 .as_ref()
6451 .is_some_and(|c| c.npc_id == closed.npc_id)
6452 {
6453 self.state.show_npc_chat = false;
6454 self.state.npc_chat = None;
6455 }
6456 }
6457 SessionEvent::NpcTalkError(err) => {
6458 self.state.push_log(format!("Talk failed: {}", err.reason));
6459 if let Some(chat) = self.state.npc_chat.as_mut() {
6460 chat.pending = false;
6461 }
6462 }
6463 SessionEvent::UseResult(result) => {
6464 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
6467 *qty = qty.saturating_sub(1);
6468 if *qty == 0 {
6469 self.state.inventory.remove(&result.template_id);
6470 }
6471 }
6472 }
6473 SessionEvent::QuestOffer(offer) => {
6474 self.state.pending_quest_offer = Some(offer.clone());
6475 self.state.show_quest_offer = true;
6476 self.state
6477 .push_log(format!("Quest offered: {}", offer.title));
6478 }
6479 SessionEvent::QuestAccepted(notice) => {
6480 self.state.show_quest_offer = false;
6481 self.state.pending_quest_offer = None;
6482 self.state.push_log(notice.message);
6483 }
6484 SessionEvent::QuestWithdrawn(notice) => {
6485 self.state.show_quest_menu = false;
6486 self.state.quest_withdraw_confirm = false;
6487 self.state.push_log(notice.message);
6488 }
6489 SessionEvent::QuestStepCompleted(notice) => {
6490 self.state.push_log(notice.message);
6491 }
6492 SessionEvent::QuestCompleted(notice) => {
6493 self.state.push_log(notice.message);
6494 }
6495 SessionEvent::Disconnected { reason } => {
6496 self.state.clear_harvest_state();
6497 self.state.connected = false;
6498 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
6499 if let Some(r) = &self.state.disconnect_reason {
6500 self.state.push_log(format!("Disconnected: {r}"));
6501 } else {
6502 self.state.push_log("Disconnected from server");
6503 }
6504 }
6505 }
6506 Ok(())
6507 }
6508
6509 pub fn is_connected(&self) -> bool {
6510 self.state.connected
6511 }
6512
6513 pub fn close_overlays(&mut self) {
6514 self.state.show_stats = false;
6515 self.state.show_craft_menu = false;
6516 self.state.show_shop_menu = false;
6517 self.state.shop_catalog = None;
6518 self.state.show_npc_verb_menu = false;
6519 self.state.npc_verb_target = None;
6520 self.state.show_npc_chat = false;
6521 self.state.npc_chat = None;
6522 self.state.show_inventory_menu = false;
6523 self.state.show_loadout_menu = false;
6524 self.state.show_rotation_editor = false;
6525 self.state.rotation_editor.reset();
6526 self.state.show_rename_prompt = false;
6527 self.state.show_worker_rename = false;
6528 self.state.rename_buffer.clear();
6529 self.state.show_move_picker = false;
6530 self.state.move_picker = None;
6531 self.state.show_destroy_picker = false;
6532 self.state.destroy_confirm_pending = false;
6533 self.state.destroy_picker = None;
6534 self.state.show_quest_offer = false;
6535 self.state.pending_quest_offer = None;
6536 self.state.show_quest_menu = false;
6537 self.state.quest_withdraw_confirm = false;
6538 self.state.show_workers_menu = false;
6539 self.close_worker_give_picker();
6540 self.close_worker_give_target_picker();
6541 self.close_worker_take_picker();
6542 self.close_worker_teach_picker();
6543 self.state.worker_route_editor = None;
6544 self.state.claim_mode = None;
6545 self.state.relocate_mode = None;
6546 self.state.sell_plot_confirm = None;
6547 self.state.sell_plot_armed_at = None;
6548 self.close_farm_access_panel();
6549 if self.state.show_plant_menu {
6550 self.close_plant_menu();
6551 }
6552 }
6553
6554 pub fn back_on_esc(&mut self) -> bool {
6556 if self.state.social_chat.composer_open() {
6557 self.state.social_chat.close_composer();
6558 return true;
6559 }
6560 if self.state.player_verbs.open {
6561 self.state.player_verbs.close();
6562 return true;
6563 }
6564 if self.state.whisper_pouch_ui.open {
6565 self.state.whisper_pouch_ui.open = false;
6566 return true;
6567 }
6568 if self.state.trade_ui.panel.is_some() {
6569 self.state.trade_ui.close();
6571 return true;
6572 }
6573 if self.state.show_rename_prompt {
6574 self.cancel_rename_prompt();
6575 return true;
6576 }
6577 if self.state.show_worker_rename {
6578 self.cancel_worker_rename();
6579 return true;
6580 }
6581 if self.state.show_destroy_picker {
6582 if self.state.destroy_confirm_pending {
6583 self.cancel_destroy_confirm();
6584 } else {
6585 self.close_destroy_picker();
6586 }
6587 return true;
6588 }
6589 if self.state.claim_mode.is_some() {
6590 self.cancel_claim_mode();
6591 return true;
6592 }
6593 if self.state.relocate_mode.is_some() {
6594 self.cancel_relocate_mode();
6595 return true;
6596 }
6597 if self.state.show_plant_menu {
6598 self.close_plant_menu();
6599 return true;
6600 }
6601 if self.state.show_farm_access {
6602 self.close_farm_access_panel();
6603 return true;
6604 }
6605 if self.state.sell_plot_confirm.is_some() {
6606 self.state.sell_plot_confirm = None;
6607 self.state.sell_plot_armed_at = None;
6608 self.state.push_log("Sell cancelled");
6609 return true;
6610 }
6611 if self.state.show_move_picker {
6612 self.close_move_picker();
6613 return true;
6614 }
6615 if self.state.show_rotation_editor {
6616 match self.state.rotation_editor.mode {
6617 RotationEditorMode::List => {
6618 self.state.show_rotation_editor = false;
6619 self.state.rotation_editor.reset();
6620 }
6621 RotationEditorMode::EditLabel => {
6622 self.state.rotation_editor.label_buffer.clear();
6623 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
6624 }
6625 RotationEditorMode::PickAbility => {
6626 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
6627 }
6628 RotationEditorMode::EditSequence => {
6629 self.state.rotation_editor.draft = None;
6630 self.state.rotation_editor.mode = RotationEditorMode::List;
6631 }
6632 }
6633 return true;
6634 }
6635 if self.state.show_inventory_menu {
6636 self.close_inventory_menu();
6637 return true;
6638 }
6639 if self.state.show_craft_menu {
6640 self.close_craft_menu();
6641 return true;
6642 }
6643 if self.state.show_keychain_menu {
6644 self.close_keychain_menu();
6645 return true;
6646 }
6647 if self.state.show_quest_offer {
6648 self.quest_offer_decline();
6649 return true;
6650 }
6651 if self.state.show_shop_menu {
6652 return false;
6654 }
6655 if self.state.bank_panel.is_some() {
6656 return false;
6657 }
6658 if self.state.storage_panel.is_some() {
6659 return false;
6660 }
6661 if self.state.market_panel.is_some() {
6662 return false;
6663 }
6664 if self.state.show_npc_chat {
6665 return false;
6667 }
6668 if self.state.show_npc_verb_menu {
6669 self.state.show_npc_verb_menu = false;
6670 self.state.npc_verb_target = None;
6671 return true;
6672 }
6673 if self.state.show_quest_menu {
6674 if self.state.quest_withdraw_confirm {
6675 self.state.quest_withdraw_confirm = false;
6676 } else {
6677 self.state.show_quest_menu = false;
6678 }
6679 return true;
6680 }
6681 if self.state.worker_route_editor.is_some() {
6682 if self.re_at_root_sheet() {
6684 let reopen = self.state.attending_worker_instance_id.clone();
6685 self.close_worker_route_editor();
6686 if let Some(id) = reopen {
6687 if let Some(idx) = self
6688 .state
6689 .hired_workers
6690 .iter()
6691 .position(|w| w.instance_id == id)
6692 {
6693 self.state.workers_menu_index = idx;
6694 self.state.show_workers_menu = true;
6695 }
6696 }
6697 } else {
6698 self.re_sheet_back();
6699 }
6700 return true;
6701 }
6702 if self.state.show_worker_give_picker {
6703 self.close_worker_give_picker();
6704 return true;
6705 }
6706 if self.state.show_worker_give_target_picker {
6707 self.close_worker_give_target_picker();
6708 return true;
6709 }
6710 if self.state.show_worker_take_picker {
6711 self.close_worker_take_picker();
6712 return true;
6713 }
6714 if self.state.show_worker_teach_picker {
6715 self.close_worker_teach_picker();
6716 return true;
6717 }
6718 if self.state.show_workers_menu {
6719 self.close_workers_menu_ui();
6720 return true;
6721 }
6722 if self.state.show_loadout_menu {
6723 self.state.show_loadout_menu = false;
6724 return true;
6725 }
6726 if self.state.show_stats {
6727 self.state.show_stats = false;
6728 return true;
6729 }
6730 if self.state.show_equip_menu {
6731 self.state.show_equip_menu = false;
6732 return true;
6733 }
6734 false
6735 }
6736
6737 pub fn toggle_stats(&mut self) {
6738 self.state.show_stats = !self.state.show_stats;
6739 if self.state.show_stats {
6740 self.state.character_sheet_tab = CharacterSheetTab::Character;
6741 self.state.show_craft_menu = false;
6742 self.state.show_shop_menu = false;
6743 self.state.shop_catalog = None;
6744 self.state.show_inventory_menu = false;
6745 self.state.show_equip_menu = false;
6746 }
6747 }
6748
6749 pub fn toggle_equip_menu(&mut self) {
6750 self.state.show_equip_menu = !self.state.show_equip_menu;
6751 if self.state.show_equip_menu {
6752 self.state.show_stats = false;
6753 self.state.show_craft_menu = false;
6754 self.state.show_shop_menu = false;
6755 self.state.shop_catalog = None;
6756 self.state.show_inventory_menu = false;
6757 self.state.show_loadout_menu = false;
6758 }
6759 }
6760
6761 pub fn cycle_character_sheet_tab(&mut self) {
6762 if self.state.show_stats {
6763 self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
6764 }
6765 }
6766
6767 pub fn set_ledger_period_digit(&mut self, c: char) {
6768 if self.state.show_stats {
6769 if let Some(p) = LedgerPeriod::from_digit(c) {
6770 self.state.ledger_period = p;
6771 self.state.character_sheet_tab = CharacterSheetTab::Ledger;
6772 }
6773 }
6774 }
6775
6776 pub fn cycle_ledger_period(&mut self) {
6777 if self.state.show_stats
6778 && self.state.character_sheet_tab == CharacterSheetTab::Ledger
6779 {
6780 self.state.ledger_period = self.state.ledger_period.cycle();
6781 }
6782 }
6783
6784 pub fn open_inventory_menu(&mut self) {
6785 self.state.show_inventory_menu = true;
6786 self.state.show_craft_menu = false;
6787 self.state.show_shop_menu = false;
6788 self.state.shop_catalog = None;
6789 self.state.show_stats = false;
6790 self.state.show_move_picker = false;
6791 self.state.move_picker = None;
6792 self.state.show_destroy_picker = false;
6793 self.state.destroy_confirm_pending = false;
6794 self.state.destroy_picker = None;
6795 self.state.show_rename_prompt = false;
6796 self.state.rename_buffer.clear();
6797 self.state.inventory_filter_focused = false;
6798 self.state.clamp_inventory_indices();
6799 }
6800
6801 pub fn close_inventory_menu(&mut self) {
6802 self.state.show_inventory_menu = false;
6803 self.state.show_move_picker = false;
6804 self.state.move_picker = None;
6805 self.close_grant_picker();
6806 self.state.show_destroy_picker = false;
6807 self.state.destroy_confirm_pending = false;
6808 self.state.destroy_picker = None;
6809 self.state.show_rename_prompt = false;
6810 self.state.rename_buffer.clear();
6811 self.state.inventory_filter_focused = false;
6812 }
6813
6814 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
6815 let Some(row) = self.state.inventory_selected_row() else {
6816 anyhow::bail!("inventory empty");
6817 };
6818 if !self.state.row_is_renameable_container(&row) {
6819 anyhow::bail!("only storage containers can be renamed");
6820 }
6821 let current = row
6822 .stack
6823 .display_name
6824 .clone()
6825 .unwrap_or_else(|| row.stack.template_id.clone());
6826 self.state.rename_buffer = current;
6827 self.state.show_rename_prompt = true;
6828 self.state.show_worker_rename = false;
6829 self.state.show_move_picker = false;
6830 self.state.show_destroy_picker = false;
6831 self.state.destroy_confirm_pending = false;
6832 Ok(())
6833 }
6834
6835 pub fn cancel_rename_prompt(&mut self) {
6836 self.state.show_rename_prompt = false;
6837 self.state.rename_buffer.clear();
6838 }
6839
6840 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
6841 let name = self.state.rename_buffer.trim().to_string();
6842 if name.is_empty() {
6843 anyhow::bail!("name cannot be empty");
6844 }
6845 let Some(row) = self.state.inventory_selected_row() else {
6846 anyhow::bail!("inventory empty");
6847 };
6848 let Some(instance_id) = row.stack.item_instance_id else {
6849 anyhow::bail!("item has no instance id");
6850 };
6851 self.seq += 1;
6852 self.session
6853 .submit_intent(Intent::RenameContainer {
6854 entity_id: self.state.entity_id,
6855 item_instance_id: instance_id,
6856 location: row.from.clone(),
6857 name,
6858 seq: self.seq,
6859 })
6860 .await?;
6861 self.state.intents_sent += 1;
6862 self.state.show_rename_prompt = false;
6863 self.state.rename_buffer.clear();
6864 Ok(())
6865 }
6866
6867 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
6868 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
6869 anyhow::bail!("no worker selected");
6870 };
6871 self.state.rename_buffer = worker.label.clone();
6872 self.state.show_worker_rename = true;
6873 self.state.show_rename_prompt = false;
6874 Ok(())
6875 }
6876
6877 pub fn cancel_worker_rename(&mut self) {
6878 self.state.show_worker_rename = false;
6879 self.state.rename_buffer.clear();
6880 }
6881
6882 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
6883 let name = self.state.rename_buffer.trim().to_string();
6884 if name.is_empty() {
6885 anyhow::bail!("name cannot be empty");
6886 }
6887 if name.chars().count() > 32 {
6888 anyhow::bail!("name must be 1–32 characters");
6889 }
6890 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
6891 anyhow::bail!("no worker selected");
6892 };
6893 let worker_instance_id = worker.instance_id.clone();
6894 self.seq += 1;
6895 self.session
6896 .submit_intent(Intent::RenameHiredWorker {
6897 entity_id: self.state.entity_id,
6898 worker_instance_id: worker_instance_id.clone(),
6899 name: name.clone(),
6900 seq: self.seq,
6901 })
6902 .await?;
6903 self.state.intents_sent += 1;
6904 if let Some(w) = self
6905 .state
6906 .hired_workers
6907 .iter_mut()
6908 .find(|w| w.instance_id == worker_instance_id)
6909 {
6910 w.label = name.clone();
6911 }
6912 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6913 if ed.worker_instance_id == worker_instance_id {
6914 ed.worker_label = name.clone();
6915 }
6916 }
6917 self.state.show_worker_rename = false;
6918 self.state.rename_buffer.clear();
6919 self.state.push_log(format!("Renamed worker to \"{name}\""));
6920 Ok(())
6921 }
6922
6923 pub fn toggle_inventory_menu(&mut self) {
6924 if self.state.show_inventory_menu {
6925 self.close_inventory_menu();
6926 } else {
6927 self.open_inventory_menu();
6928 }
6929 }
6930
6931 pub fn inventory_menu_move(&mut self, delta: i32) {
6933 if self.state.show_grant_picker {
6934 let Some(picker) = self.state.grant_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.grant_picker_index = step_filtered_index(
6944 self.state.grant_picker_index,
6945 delta,
6946 n,
6947 |i| list_label_matches(&labels[i], &filter),
6948 );
6949 return;
6950 }
6951 if self.state.show_move_picker {
6952 let Some(picker) = self.state.move_picker.as_ref() else {
6953 return;
6954 };
6955 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6956 let filter = picker.filter.clone();
6957 let n = labels.len();
6958 if n == 0 {
6959 return;
6960 }
6961 self.state.move_picker_index = step_filtered_index(
6962 self.state.move_picker_index,
6963 delta,
6964 n,
6965 |i| list_label_matches(&labels[i], &filter),
6966 );
6967 self.state.clamp_move_picker_quantity();
6968 return;
6969 }
6970 let n = self.state.inventory_selectable_rows().len();
6971 if n == 0 {
6972 return;
6973 }
6974 let idx = self.state.inventory_menu_index as i32;
6975 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6976 }
6977
6978 pub fn inventory_menu_page(&mut self, pages: i32) {
6980 if self.state.show_grant_picker {
6981 let Some(picker) = self.state.grant_picker.as_ref() else {
6982 return;
6983 };
6984 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6985 let filter = picker.filter.clone();
6986 let n = labels.len();
6987 self.state.grant_picker_index = page_filtered_index(
6988 self.state.grant_picker_index,
6989 pages,
6990 n,
6991 |i| list_label_matches(&labels[i], &filter),
6992 );
6993 return;
6994 }
6995 if self.state.show_move_picker {
6996 let Some(picker) = self.state.move_picker.as_ref() else {
6997 return;
6998 };
6999 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7000 let filter = picker.filter.clone();
7001 let n = labels.len();
7002 self.state.move_picker_index = page_filtered_index(
7003 self.state.move_picker_index,
7004 pages,
7005 n,
7006 |i| list_label_matches(&labels[i], &filter),
7007 );
7008 self.state.clamp_move_picker_quantity();
7009 return;
7010 }
7011 let n = self.state.inventory_selectable_rows().len();
7012 self.state.inventory_menu_index =
7013 page_list_index(self.state.inventory_menu_index, pages, n);
7014 }
7015
7016 pub fn cycle_inventory_tab(&mut self, forward: bool) {
7017 if self.state.show_move_picker
7018 || self.state.show_grant_picker
7019 || self.state.show_destroy_picker
7020 || self.state.show_rename_prompt
7021 || self.state.inventory_filter_focused
7022 {
7023 return;
7024 }
7025 self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
7026 self.state.inventory_menu_index = 0;
7027 self.state.clamp_inventory_indices();
7028 }
7029
7030 pub fn focus_inventory_filter(&mut self) {
7031 if self.state.show_grant_picker {
7032 if let Some(p) = self.state.grant_picker.as_mut() {
7033 p.filter_focused = true;
7034 }
7035 return;
7036 }
7037 if self.state.show_move_picker {
7038 if let Some(p) = self.state.move_picker.as_mut() {
7039 p.filter_focused = true;
7040 }
7041 return;
7042 }
7043 self.state.inventory_filter_focused = true;
7044 }
7045
7046 pub fn set_inventory_filter(&mut self, filter: String) {
7047 self.state.inventory_filter = filter;
7048 self.state.inventory_menu_index = 0;
7049 self.state.clamp_inventory_indices();
7050 }
7051
7052 pub fn append_inventory_filter_char(&mut self, ch: char) {
7053 if ch.is_control() {
7054 return;
7055 }
7056 if self.state.show_grant_picker {
7057 if let Some(p) = self.state.grant_picker.as_mut() {
7058 if p.filter_focused {
7059 p.filter.push(ch);
7060 self.state.grant_picker_index = 0;
7061 }
7062 }
7063 return;
7064 }
7065 if self.state.show_move_picker {
7066 if let Some(p) = self.state.move_picker.as_mut() {
7067 if p.filter_focused {
7068 p.filter.push(ch);
7069 self.state.move_picker_index = 0;
7070 self.state.clamp_move_picker_quantity();
7071 }
7072 }
7073 return;
7074 }
7075 if !self.state.inventory_filter_focused {
7076 return;
7077 }
7078 self.state.inventory_filter.push(ch);
7079 self.state.inventory_menu_index = 0;
7080 self.state.clamp_inventory_indices();
7081 }
7082
7083 pub fn inventory_filter_backspace(&mut self) {
7084 if self.state.show_grant_picker {
7085 if let Some(p) = self.state.grant_picker.as_mut() {
7086 if p.filter_focused {
7087 p.filter.pop();
7088 self.state.grant_picker_index = 0;
7089 }
7090 }
7091 return;
7092 }
7093 if self.state.show_move_picker {
7094 if let Some(p) = self.state.move_picker.as_mut() {
7095 if p.filter_focused {
7096 p.filter.pop();
7097 self.state.move_picker_index = 0;
7098 self.state.clamp_move_picker_quantity();
7099 }
7100 }
7101 return;
7102 }
7103 if !self.state.inventory_filter_focused {
7104 return;
7105 }
7106 self.state.inventory_filter.pop();
7107 self.state.inventory_menu_index = 0;
7108 self.state.clamp_inventory_indices();
7109 }
7110
7111 pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
7113 if self.state.show_grant_picker {
7114 if let Some(p) = self.state.grant_picker.as_mut() {
7115 if p.filter_focused {
7116 if !p.filter.is_empty() {
7117 p.filter.clear();
7118 self.state.grant_picker_index = 0;
7119 } else {
7120 p.filter_focused = false;
7121 }
7122 return true;
7123 }
7124 if !p.filter.is_empty() {
7125 p.filter.clear();
7126 self.state.grant_picker_index = 0;
7127 return true;
7128 }
7129 }
7130 return false;
7131 }
7132 if self.state.show_move_picker {
7133 if let Some(p) = self.state.move_picker.as_mut() {
7134 if p.filter_focused {
7135 if !p.filter.is_empty() {
7136 p.filter.clear();
7137 self.state.move_picker_index = 0;
7138 self.state.clamp_move_picker_quantity();
7139 } else {
7140 p.filter_focused = false;
7141 }
7142 return true;
7143 }
7144 if !p.filter.is_empty() {
7145 p.filter.clear();
7146 self.state.move_picker_index = 0;
7147 self.state.clamp_move_picker_quantity();
7148 return true;
7149 }
7150 }
7151 return false;
7152 }
7153 if self.state.inventory_filter_focused {
7154 if !self.state.inventory_filter.is_empty() {
7155 self.state.inventory_filter.clear();
7156 self.state.inventory_menu_index = 0;
7157 self.state.clamp_inventory_indices();
7158 } else {
7159 self.state.inventory_filter_focused = false;
7160 }
7161 return true;
7162 }
7163 if !self.state.inventory_filter.is_empty() {
7164 self.state.inventory_filter.clear();
7165 self.state.inventory_menu_index = 0;
7166 self.state.clamp_inventory_indices();
7167 return true;
7168 }
7169 false
7170 }
7171
7172 pub fn craft_menu_page(&mut self, pages: i32) {
7173 let n = self.state.blueprints.len();
7174 self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
7175 self.state.clamp_craft_batch_quantity();
7176 }
7177
7178 pub fn shop_menu_page(&mut self, pages: i32) {
7179 let n = self.state.shop_list_len();
7180 self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
7181 self.state.clamp_shop_quantity();
7182 }
7183
7184 pub fn workers_menu_page(&mut self, pages: i32) {
7185 let n = self.state.hired_workers.len();
7186 self.state.workers_menu_index =
7187 page_list_index(self.state.workers_menu_index, pages, n);
7188 }
7189
7190 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
7195 if self.state.show_destroy_picker {
7196 if self.state.destroy_confirm_pending {
7197 return self.confirm_destroy_item().await;
7198 }
7199 return self.request_destroy_confirm();
7200 }
7201 if self.state.show_grant_picker {
7202 return self.confirm_grant_picker().await;
7203 }
7204 if self.state.show_move_picker {
7205 return self.confirm_move_picker().await;
7206 }
7207 let Some(row) = self.state.inventory_selected_row() else {
7208 anyhow::bail!("inventory empty");
7209 };
7210 if row.is_equip_shell {
7211 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
7212 anyhow::bail!("not a worn item");
7213 };
7214 return self.equip_worn(slot, None).await;
7215 }
7216 if row.is_chest_shell {
7217 return self.open_chest_pickup_picker();
7218 }
7219 let template_id = row.stack.template_id.clone();
7220 let instance_id = row.stack.item_instance_id;
7221 let category = self.state.inventory_item_category(&template_id);
7222 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7223
7224 if category == Some("weapon") {
7225 return self.equip_mainhand(Some(template_id)).await;
7226 }
7227 if category == Some("lodging") && on_person {
7228 if let Some(inst) = instance_id {
7229 return self.place_container(inst).await;
7230 }
7231 }
7232 if (category == Some("container") || category == Some("armor")) && on_person {
7233 if let Some(inst) = instance_id {
7234 let world_placeable = row.stack.world_placeable == Some(true)
7235 || template_id.contains("chest");
7236 if world_placeable {
7237 return self.place_container(inst).await;
7238 }
7239 if let Some(slot) = guess_body_slot(&template_id) {
7243 return self.equip_worn(slot, Some(inst)).await;
7244 }
7245 }
7246 }
7247 self.open_move_picker()
7251 }
7252
7253 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
7255 let Some(row) = self.state.inventory_selected_row() else {
7256 anyhow::bail!("inventory empty");
7257 };
7258 if row.from != flatland_protocol::InventoryLocation::Root {
7259 anyhow::bail!("select a consumable on your person");
7260 }
7261 if GameState::stack_is_item_grant(&row.stack) {
7262 return self.open_grant_target_picker();
7263 }
7264 if GameState::is_property_deed_template(&row.stack.template_id) {
7265 return self.open_move_picker();
7266 }
7267 let category = self
7268 .state
7269 .inventory_item_category(&row.stack.template_id);
7270 if category != Some("consumable") {
7271 anyhow::bail!("selected item is not consumable");
7272 }
7273 self.use_item(&row.stack.template_id).await
7274 }
7275
7276 pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
7278 let Some(row) = self.state.inventory_selected_row() else {
7279 anyhow::bail!("inventory empty");
7280 };
7281 if row.from != flatland_protocol::InventoryLocation::Root {
7282 anyhow::bail!("select a grant item on your person");
7283 }
7284 if !GameState::stack_is_item_grant(&row.stack) {
7285 anyhow::bail!("selected item does not grant onto gear");
7286 }
7287 let Some(grant_instance_id) = row.stack.item_instance_id else {
7288 anyhow::bail!("grant has no instance id");
7289 };
7290 let effect_id = GameState::grant_effect_id(&row.stack)
7291 .unwrap_or("?")
7292 .to_string();
7293 let mode = GameState::grant_mode(&row.stack).to_string();
7294 let options = self.state.grant_target_options(&row.stack);
7295 if options.is_empty() {
7296 anyhow::bail!("no valid gear to apply {effect_id} to");
7297 }
7298 let grant_label = row
7299 .stack
7300 .display_name
7301 .clone()
7302 .unwrap_or_else(|| row.stack.template_id.clone());
7303 self.state.show_grant_picker = true;
7304 self.state.grant_picker_index = 0;
7305 self.state.grant_picker = Some(GrantTargetPicker {
7306 grant_instance_id,
7307 grant_label,
7308 effect_id,
7309 mode,
7310 options,
7311 filter: String::new(),
7312 filter_focused: false,
7313 });
7314 Ok(())
7315 }
7316
7317 pub fn close_grant_picker(&mut self) {
7318 self.state.show_grant_picker = false;
7319 self.state.grant_picker = None;
7320 self.state.grant_picker_index = 0;
7321 }
7322
7323 pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
7324 let Some(picker) = self.state.grant_picker.clone() else {
7325 self.close_grant_picker();
7326 return Ok(());
7327 };
7328 let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
7329 self.close_grant_picker();
7330 return Ok(());
7331 };
7332 self.close_grant_picker();
7333 self.use_grant(picker.grant_instance_id, opt.target_instance_id)
7334 .await?;
7335 self.state.push_log(format!(
7336 "Applying {} onto {}…",
7337 picker.effect_id, opt.label
7338 ));
7339 Ok(())
7340 }
7341
7342 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
7346 let Some(row) = self.state.inventory_selected_row() else {
7347 anyhow::bail!("inventory empty");
7348 };
7349 if row.is_equip_shell {
7350 anyhow::bail!("this is a worn bag — press Enter to unequip it");
7351 }
7352 if row.is_chest_shell {
7353 return self.open_chest_pickup_picker();
7354 }
7355 let Some(instance_id) = row.stack.item_instance_id else {
7356 anyhow::bail!("item has no instance id");
7357 };
7358 let mut options = self.state.move_destinations_for(
7359 &row.from,
7360 row.from_parent_instance_id,
7361 row.stack.item_instance_id,
7362 &row.stack.template_id,
7363 );
7364 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7365 let category = self.state.inventory_item_category(&row.stack.template_id);
7366 if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
7367 if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
7368 options.insert(
7369 0,
7370 MoveOption {
7371 label: "Sell plot to crown…".into(),
7372 kind: MoveOptionKind::SellPlotToCrown { plot_id },
7373 },
7374 );
7375 }
7376 }
7377 if on_person && category == Some("consumable") {
7378 if GameState::stack_is_item_grant(&row.stack) {
7379 options.insert(
7380 0,
7381 MoveOption {
7382 label: "Apply onto gear…".into(),
7383 kind: MoveOptionKind::GrantApply,
7384 },
7385 );
7386 } else {
7387 options.insert(
7388 0,
7389 MoveOption {
7390 label: "Use (eat / drink)".into(),
7391 kind: MoveOptionKind::Use,
7392 },
7393 );
7394 }
7395 }
7396 let item_label = row
7397 .stack
7398 .display_name
7399 .clone()
7400 .unwrap_or_else(|| row.stack.template_id.clone());
7401 let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
7404 self.state.move_picker = Some(MovePicker {
7405 item_instance_id: instance_id,
7406 from: row.from,
7407 item_label,
7408 template_id: row.stack.template_id.clone(),
7409 stack_quantity: row.stack.quantity,
7410 quantity: initial_qty.max(1),
7411 options,
7412 filter: String::new(),
7413 filter_focused: false,
7414 });
7415 self.state.move_picker_index = 0;
7416 self.state.show_move_picker = true;
7417 self.state.show_destroy_picker = false;
7418 self.state.destroy_confirm_pending = false;
7419 self.state.destroy_picker = None;
7420 self.state.clamp_move_picker_quantity();
7421 Ok(())
7422 }
7423
7424 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
7426 let Some(row) = self.state.inventory_selected_row() else {
7427 anyhow::bail!("inventory empty");
7428 };
7429 if !row.is_chest_shell {
7430 anyhow::bail!("not a placed chest");
7431 }
7432 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
7433 anyhow::bail!("not a placed chest");
7434 };
7435 let Some(instance_id) = row.stack.item_instance_id else {
7436 anyhow::bail!("chest has no instance id");
7437 };
7438 let chest = self
7439 .state
7440 .placed_containers
7441 .iter()
7442 .find(|c| c.id == *container_id)
7443 .cloned()
7444 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
7445 let (px, py) = self.state.player_position();
7446 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
7447 anyhow::bail!("too far from {}", chest.display_name);
7448 }
7449 if chest.locked && !chest.accessible {
7450 anyhow::bail!(
7451 "need the matching key for {} before picking it up",
7452 chest.display_name
7453 );
7454 }
7455 let options = self.state.chest_pickup_destinations(container_id);
7456 let item_label = row
7457 .stack
7458 .display_name
7459 .clone()
7460 .unwrap_or_else(|| row.stack.template_id.clone());
7461 self.state.move_picker = Some(MovePicker {
7462 item_instance_id: instance_id,
7463 from: row.from.clone(),
7464 item_label,
7465 template_id: row.stack.template_id.clone(),
7466 stack_quantity: 1,
7467 quantity: 1,
7468 options,
7469 filter: String::new(),
7470 filter_focused: false,
7471 });
7472 self.state.move_picker_index = 0;
7473 self.state.show_move_picker = true;
7474 self.state.show_destroy_picker = false;
7475 self.state.destroy_confirm_pending = false;
7476 self.state.destroy_picker = None;
7477 Ok(())
7478 }
7479
7480 pub fn close_move_picker(&mut self) {
7481 self.state.show_move_picker = false;
7482 self.state.move_picker = None;
7483 }
7484
7485 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
7486 self.state.move_picker_adjust_quantity(delta);
7487 }
7488
7489 pub fn move_picker_set_quantity_max(&mut self) {
7490 self.state.move_picker_set_quantity_max();
7491 }
7492
7493 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
7494 self.state.destroy_picker_adjust_quantity(delta);
7495 }
7496
7497 pub fn destroy_picker_set_quantity_max(&mut self) {
7498 self.state.destroy_picker_set_quantity_max();
7499 }
7500
7501 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
7502 let Some(picker) = self.state.move_picker.clone() else {
7503 self.close_move_picker();
7504 return Ok(());
7505 };
7506 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
7507 self.close_move_picker();
7508 return Ok(());
7509 };
7510 match option.kind {
7511 MoveOptionKind::Cancel => {
7512 self.close_move_picker();
7513 }
7514 MoveOptionKind::Use => {
7515 self.close_move_picker();
7516 self.use_item(&picker.template_id).await?;
7517 }
7518 MoveOptionKind::GrantApply => {
7519 self.close_move_picker();
7520 self.open_grant_target_picker()?;
7521 }
7522 MoveOptionKind::SellPlotToCrown { plot_id } => {
7523 self.close_move_picker();
7524 self.confirm_sell_plot_to_crown(plot_id).await?;
7525 }
7526 MoveOptionKind::RelocatePlaced { container_id } => {
7527 self.close_move_picker();
7528 self.state.show_inventory_menu = false;
7529 self.begin_relocate_container(&container_id)?;
7530 }
7531 MoveOptionKind::Drop => {
7532 self.close_move_picker();
7533 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
7534 if self.state.deed_bound(&stack) {
7535 anyhow::bail!(
7536 "cannot drop a property deed — store it or trade it to another player"
7537 );
7538 }
7539 if self.state.key_drop_blocked(&stack) {
7540 anyhow::bail!("cannot drop the key while its chest is locked");
7541 }
7542 }
7543 self.drop_item(picker.item_instance_id, picker.from).await?;
7544 self.state
7545 .push_log(format!("Dropped {}", picker.item_label));
7546 }
7547 MoveOptionKind::PickupPlaced {
7548 container_id,
7549 nest_location,
7550 nest_parent_instance_id,
7551 } => {
7552 self.close_move_picker();
7553 self.pickup_container(container_id.clone()).await?;
7554 let nest_into_bag = nest_parent_instance_id.is_some()
7555 || !matches!(
7556 nest_location,
7557 flatland_protocol::InventoryLocation::Root
7558 );
7559 if nest_into_bag {
7560 self.move_item(
7561 picker.item_instance_id,
7562 flatland_protocol::InventoryLocation::Root,
7563 nest_location,
7564 nest_parent_instance_id,
7565 None,
7566 )
7567 .await?;
7568 self.state
7569 .push_log(format!("Picked up {} into bag", picker.item_label));
7570 } else {
7571 self.state
7572 .push_log(format!("Picked up {}", picker.item_label));
7573 }
7574 }
7575 MoveOptionKind::Move {
7576 location,
7577 parent_instance_id,
7578 } => {
7579 self.close_move_picker();
7580 let qty = if picker.quantity >= picker.stack_quantity {
7581 None
7582 } else {
7583 Some(picker.quantity)
7584 };
7585 self.move_item(
7586 picker.item_instance_id,
7587 picker.from,
7588 location,
7589 parent_instance_id,
7590 qty,
7591 )
7592 .await?;
7593 let moved = qty.unwrap_or(picker.stack_quantity);
7594 if moved >= picker.stack_quantity {
7595 self.state.push_log(format!("Moved {}", picker.item_label));
7596 } else {
7597 self.state.push_log(format!(
7598 "Moved {} ×{} of {}",
7599 picker.item_label, moved, picker.stack_quantity
7600 ));
7601 }
7602 }
7603 }
7604 Ok(())
7605 }
7606
7607 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
7609 let Some(row) = self.state.inventory_selected_row() else {
7610 anyhow::bail!("inventory empty");
7611 };
7612 if row.is_equip_shell {
7613 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
7614 }
7615 if row.is_chest_shell {
7616 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
7617 }
7618 let Some(inst) = row.stack.item_instance_id else {
7619 anyhow::bail!("item has no instance id");
7620 };
7621 if self.state.deed_bound(&row.stack) {
7622 anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
7623 }
7624 if self.state.key_drop_blocked(&row.stack) {
7625 anyhow::bail!("cannot drop the key while its chest is locked");
7626 }
7627 let label = row
7628 .stack
7629 .display_name
7630 .clone()
7631 .unwrap_or_else(|| row.stack.template_id.clone());
7632 self.drop_item(inst, row.from).await?;
7633 self.state.push_log(format!("Dropped {label}"));
7634 Ok(())
7635 }
7636
7637 pub async fn drop_item(
7638 &mut self,
7639 item_instance_id: uuid::Uuid,
7640 from: flatland_protocol::InventoryLocation,
7641 ) -> anyhow::Result<()> {
7642 self.seq += 1;
7643 self.session
7644 .submit_intent(Intent::DropItem {
7645 entity_id: self.state.entity_id,
7646 item_instance_id,
7647 from,
7648 seq: self.seq,
7649 })
7650 .await?;
7651 self.state.intents_sent += 1;
7652 Ok(())
7653 }
7654
7655 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
7657 let Some(row) = self.state.inventory_selected_row() else {
7658 anyhow::bail!("inventory empty");
7659 };
7660 if row.is_equip_shell {
7661 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
7662 }
7663 if row.is_chest_shell {
7664 anyhow::bail!("can't destroy a placed chest from the inventory list");
7665 }
7666 let Some(instance_id) = row.stack.item_instance_id else {
7667 anyhow::bail!("item has no instance id");
7668 };
7669 if self.state.deed_bound(&row.stack) {
7670 anyhow::bail!(
7671 "cannot destroy a property deed — store it or trade it to another player"
7672 );
7673 }
7674 if self.state.key_drop_blocked(&row.stack) {
7675 anyhow::bail!("cannot destroy the key while its chest is locked");
7676 }
7677 let item_label = row
7678 .stack
7679 .display_name
7680 .clone()
7681 .unwrap_or_else(|| row.stack.template_id.clone());
7682 self.state.destroy_picker = Some(DestroyPicker {
7683 item_instance_id: instance_id,
7684 from: row.from,
7685 item_label,
7686 stack_quantity: row.stack.quantity,
7687 quantity: row.stack.quantity,
7688 });
7689 self.state.destroy_confirm_pending = false;
7690 self.state.show_destroy_picker = true;
7691 self.state.show_move_picker = false;
7692 self.state.move_picker = None;
7693 Ok(())
7694 }
7695
7696 pub fn close_destroy_picker(&mut self) {
7697 self.state.show_destroy_picker = false;
7698 self.state.destroy_confirm_pending = false;
7699 self.state.destroy_picker = None;
7700 }
7701
7702 pub fn cancel_destroy_confirm(&mut self) {
7703 self.state.destroy_confirm_pending = false;
7704 }
7705
7706 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
7707 if self.state.destroy_picker.is_none() {
7708 self.close_destroy_picker();
7709 return Ok(());
7710 }
7711 self.state.destroy_confirm_pending = true;
7712 Ok(())
7713 }
7714
7715 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
7716 let Some(picker) = self.state.destroy_picker.clone() else {
7717 self.close_destroy_picker();
7718 return Ok(());
7719 };
7720 let qty = if picker.quantity >= picker.stack_quantity {
7721 None
7722 } else {
7723 Some(picker.quantity)
7724 };
7725 self.destroy_item(picker.item_instance_id, picker.from, qty)
7726 .await?;
7727 let destroyed = qty.unwrap_or(picker.stack_quantity);
7728 if destroyed >= picker.stack_quantity {
7729 self.state
7730 .push_log(format!("Destroyed {}", picker.item_label));
7731 } else {
7732 self.state.push_log(format!(
7733 "Destroyed {} ×{} of {}",
7734 picker.item_label, destroyed, picker.stack_quantity
7735 ));
7736 }
7737 self.close_destroy_picker();
7738 Ok(())
7739 }
7740
7741 pub async fn destroy_item(
7742 &mut self,
7743 item_instance_id: uuid::Uuid,
7744 from: flatland_protocol::InventoryLocation,
7745 quantity: Option<u32>,
7746 ) -> anyhow::Result<()> {
7747 self.seq += 1;
7748 self.session
7749 .submit_intent(Intent::DestroyItem {
7750 entity_id: self.state.entity_id,
7751 item_instance_id,
7752 from,
7753 quantity,
7754 seq: self.seq,
7755 })
7756 .await?;
7757 self.state.intents_sent += 1;
7758 Ok(())
7759 }
7760
7761 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
7763 if let Some(row) = self.state.inventory_selected_row() {
7764 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
7765 return self.toggle_placed_chest_lock(container_id).await;
7766 }
7767 }
7768 self.toggle_nearby_chest_lock().await
7769 }
7770
7771 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
7772 let chest = self
7773 .state
7774 .placed_containers
7775 .iter()
7776 .find(|c| c.id == container_id)
7777 .cloned()
7778 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
7779 let (px, py) = self.state.player_position();
7780 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
7781 anyhow::bail!("too far from {}", chest.display_name);
7782 }
7783 if !chest.accessible && chest.locked {
7784 anyhow::bail!(
7785 "need the matching key for {} (each crafted chest has its own key)",
7786 chest.display_name
7787 );
7788 }
7789 let lock = !chest.locked;
7790 self.set_container_locked(
7791 flatland_protocol::InventoryLocation::Placed {
7792 container_id: chest.id.clone(),
7793 },
7794 lock,
7795 )
7796 .await?;
7797 self.state.push_log(if lock {
7798 format!("Locked {}", chest.display_name)
7799 } else {
7800 format!("Unlocked {}", chest.display_name)
7801 });
7802 Ok(())
7803 }
7804
7805 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
7807 let chest = self
7808 .state
7809 .nearest_placed_container(CONTAINER_RANGE_M)
7810 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
7811 self.toggle_placed_chest_lock(&chest.id).await
7812 }
7813
7814 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
7815 self.equip_mainhand(None).await
7816 }
7817
7818 pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
7819 if !self.state.is_alive() {
7820 anyhow::bail!("you are dead");
7821 }
7822 self.seq += 1;
7823 self.session
7824 .submit_intent(Intent::EquipOffhand {
7825 entity_id: self.state.entity_id,
7826 template_id,
7827 instance_id: None,
7828 seq: self.seq,
7829 })
7830 .await?;
7831 self.state.intents_sent += 1;
7832 Ok(())
7833 }
7834
7835 pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
7836 self.equip_offhand(None).await
7837 }
7838
7839 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
7840 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
7841 for slot in slots {
7842 self.equip_worn(slot, None).await?;
7843 }
7844 Ok(())
7845 }
7846
7847 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
7848 let (px, py) = self.state.player_position();
7849 let nearest = self
7850 .state
7851 .placed_containers
7852 .iter()
7853 .min_by(|a, b| {
7854 let da = (a.x - px).hypot(a.y - py);
7855 let db = (b.x - px).hypot(b.y - py);
7856 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
7857 })
7858 .cloned();
7859 let Some(chest) = nearest else {
7860 anyhow::bail!("no chest nearby");
7861 };
7862 if (chest.x - px).hypot(chest.y - py) > 2.0 {
7863 anyhow::bail!("too far from chest");
7864 }
7865 self.pickup_container(chest.id).await
7866 }
7867
7868 pub async fn equip_worn(
7869 &mut self,
7870 slot: BodySlot,
7871 instance_id: Option<uuid::Uuid>,
7872 ) -> anyhow::Result<()> {
7873 self.seq += 1;
7874 self.session
7875 .submit_intent(Intent::EquipWorn {
7876 entity_id: self.state.entity_id,
7877 slot,
7878 instance_id,
7879 seq: self.seq,
7880 })
7881 .await?;
7882 self.state.intents_sent += 1;
7883 Ok(())
7884 }
7885
7886 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
7887 self.seq += 1;
7888 self.session
7889 .submit_intent(Intent::PlaceContainer {
7890 entity_id: self.state.entity_id,
7891 item_instance_id,
7892 seq: self.seq,
7893 })
7894 .await?;
7895 self.state.intents_sent += 1;
7896 Ok(())
7897 }
7898
7899 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
7900 self.seq += 1;
7901 self.session
7902 .submit_intent(Intent::PickupContainer {
7903 entity_id: self.state.entity_id,
7904 container_id,
7905 seq: self.seq,
7906 })
7907 .await?;
7908 self.state.intents_sent += 1;
7909 Ok(())
7910 }
7911
7912 pub async fn move_item(
7913 &mut self,
7914 item_instance_id: uuid::Uuid,
7915 from: flatland_protocol::InventoryLocation,
7916 to: flatland_protocol::InventoryLocation,
7917 to_parent_instance_id: Option<uuid::Uuid>,
7918 quantity: Option<u32>,
7919 ) -> anyhow::Result<()> {
7920 self.seq += 1;
7921 self.session
7922 .submit_intent(Intent::MoveItem {
7923 entity_id: self.state.entity_id,
7924 item_instance_id,
7925 from,
7926 to,
7927 to_parent_instance_id,
7928 quantity,
7929 seq: self.seq,
7930 })
7931 .await?;
7932 self.state.intents_sent += 1;
7933 Ok(())
7934 }
7935
7936 pub async fn set_container_locked(
7937 &mut self,
7938 location: flatland_protocol::InventoryLocation,
7939 locked: bool,
7940 ) -> anyhow::Result<()> {
7941 self.seq += 1;
7942 self.session
7943 .submit_intent(Intent::SetContainerLocked {
7944 entity_id: self.state.entity_id,
7945 location,
7946 locked,
7947 seq: self.seq,
7948 })
7949 .await?;
7950 self.state.intents_sent += 1;
7951 Ok(())
7952 }
7953
7954 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
7955 if !self.state.is_alive() {
7956 anyhow::bail!("you are dead");
7957 }
7958 self.seq += 1;
7959 self.session
7960 .submit_intent(Intent::Use {
7961 entity_id: self.state.entity_id,
7962 template_id: template_id.to_string(),
7963 seq: self.seq,
7964 })
7965 .await?;
7966 self.state.intents_sent += 1;
7967 Ok(())
7968 }
7969
7970 pub async fn use_grant(
7972 &mut self,
7973 grant_instance_id: uuid::Uuid,
7974 target_instance_id: uuid::Uuid,
7975 ) -> anyhow::Result<()> {
7976 if !self.state.is_alive() {
7977 anyhow::bail!("you are dead");
7978 }
7979 self.seq += 1;
7980 self.session
7981 .submit_intent(Intent::UseGrant {
7982 entity_id: self.state.entity_id,
7983 grant_instance_id,
7984 target_instance_id,
7985 seq: self.seq,
7986 })
7987 .await?;
7988 self.state.intents_sent += 1;
7989 Ok(())
7990 }
7991
7992 pub fn open_craft_menu(&mut self) {
7993 self.state.show_craft_menu = true;
7994 self.state.show_shop_menu = false;
7995 self.state.shop_catalog = None;
7996 self.state.show_stats = false;
7997 self.state.show_inventory_menu = false;
7998 if self.state.blueprints.is_empty() {
7999 self.state.craft_menu_index = 0;
8000 self.state.craft_batch_quantity = 1;
8001 return;
8002 }
8003 self.state.craft_menu_index = self
8004 .state
8005 .craft_menu_index
8006 .min(self.state.blueprints.len() - 1);
8007 if let Some(idx) = self
8008 .state
8009 .blueprints
8010 .iter()
8011 .position(|bp| self.state.can_craft_blueprint(bp))
8012 {
8013 self.state.craft_menu_index = idx;
8014 }
8015 self.state.clamp_craft_batch_quantity();
8016 }
8017
8018 pub fn close_craft_menu(&mut self) {
8019 self.state.show_craft_menu = false;
8020 }
8021
8022 pub fn toggle_keychain_menu(&mut self) {
8023 if self.state.show_keychain_menu {
8024 self.close_keychain_menu();
8025 } else {
8026 self.state.show_keychain_menu = true;
8027 self.state.show_craft_menu = false;
8028 self.state.show_shop_menu = false;
8029 self.state.show_inventory_menu = false;
8030 let n = self.state.keychain_entries().len();
8031 if n == 0 {
8032 self.state.keychain_menu_index = 0;
8033 } else {
8034 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
8035 }
8036 }
8037 }
8038
8039 pub fn close_keychain_menu(&mut self) {
8040 self.state.show_keychain_menu = false;
8041 }
8042
8043 pub fn keychain_menu_move(&mut self, delta: i32) {
8044 let n = self.state.keychain_entries().len();
8045 if n == 0 {
8046 self.state.keychain_menu_index = 0;
8047 return;
8048 }
8049 let idx = self.state.keychain_menu_index as i32 + delta;
8050 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
8051 }
8052
8053 pub fn keychain_menu_page(&mut self, pages: i32) {
8054 let n = self.state.keychain_entries().len();
8055 self.state.keychain_menu_index =
8056 page_list_index(self.state.keychain_menu_index, pages, n);
8057 }
8058
8059 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
8060 if !self.state.is_alive() {
8061 anyhow::bail!("you are dead");
8062 }
8063 let entries = self.state.keychain_entries();
8064 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
8065 anyhow::bail!("nothing selected");
8066 };
8067 let Some(instance_id) = entry.stack.item_instance_id else {
8068 anyhow::bail!("key has no instance id");
8069 };
8070 if entry.stowed {
8071 self.move_item(
8072 instance_id,
8073 flatland_protocol::InventoryLocation::Keychain,
8074 flatland_protocol::InventoryLocation::Root,
8075 None,
8076 Some(1),
8077 )
8078 .await
8079 } else {
8080 self.move_item(
8081 instance_id,
8082 flatland_protocol::InventoryLocation::Root,
8083 flatland_protocol::InventoryLocation::Keychain,
8084 None,
8085 Some(1),
8086 )
8087 .await
8088 }
8089 }
8090
8091 pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
8092 let npc_id = self
8093 .state
8094 .shop_catalog
8095 .as_ref()
8096 .map(|c| c.npc_id.clone());
8097 self.state.show_shop_menu = false;
8098 self.state.shop_catalog = None;
8099 self.state.clear_shop_trade_log();
8100 if let Some(npc_id) = npc_id {
8101 self.seq += 1;
8102 self.session
8103 .submit_intent(Intent::ShopClose {
8104 entity_id: self.state.entity_id,
8105 npc_id,
8106 seq: self.seq,
8107 })
8108 .await?;
8109 self.state.intents_sent += 1;
8110 }
8111 Ok(())
8112 }
8113
8114 pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8115 let Some(panel) = self.state.bank_panel.clone() else {
8116 return Ok(());
8117 };
8118 self.seq += 1;
8119 self.session
8120 .submit_intent(Intent::BankDeposit {
8121 entity_id: self.state.entity_id,
8122 npc_id: panel.npc_id,
8123 amount_copper,
8124 seq: self.seq,
8125 })
8126 .await?;
8127 self.state.intents_sent += 1;
8128 Ok(())
8129 }
8130
8131 pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8132 let Some(panel) = self.state.bank_panel.clone() else {
8133 return Ok(());
8134 };
8135 self.seq += 1;
8136 self.session
8137 .submit_intent(Intent::BankWithdraw {
8138 entity_id: self.state.entity_id,
8139 npc_id: panel.npc_id,
8140 amount_copper,
8141 seq: self.seq,
8142 })
8143 .await?;
8144 self.state.intents_sent += 1;
8145 Ok(())
8146 }
8147
8148 pub async fn bank_transfer(
8149 &mut self,
8150 to_character_id: Option<uuid::Uuid>,
8151 to_name: String,
8152 amount_copper: u64,
8153 ) -> anyhow::Result<()> {
8154 let Some(panel) = self.state.bank_panel.clone() else {
8155 return Ok(());
8156 };
8157 self.seq += 1;
8158 self.session
8159 .submit_intent(Intent::BankTransfer {
8160 entity_id: self.state.entity_id,
8161 npc_id: panel.npc_id,
8162 to_character_id,
8163 to_name,
8164 amount_copper,
8165 seq: self.seq,
8166 })
8167 .await?;
8168 self.state.intents_sent += 1;
8169 Ok(())
8170 }
8171
8172 pub fn bank_menu_move(&mut self, delta: i32) {
8173 let n = self.state.bank_menu_options().len();
8174 if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
8175 return;
8176 }
8177 let idx = self.state.bank_menu_index as i32;
8178 self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8179 }
8180
8181 pub fn storage_menu_move(&mut self, delta: i32) {
8182 let n = self.state.storage_menu_options().len();
8183 if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
8184 return;
8185 }
8186 let idx = self.state.storage_menu_index as i32;
8187 self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8188 }
8189
8190 pub fn storage_pick_move(&mut self, delta: i32) {
8191 let n = match &self.state.storage_ui_mode {
8192 StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
8193 StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
8194 self.state.storage_vault_options().len()
8195 }
8196 StorageUiMode::Menu
8197 | StorageUiMode::StoreAmount { .. }
8198 | StorageUiMode::TakeAmount { .. }
8199 | StorageUiMode::ShipAmount { .. } => 0,
8200 };
8201 if n == 0 {
8202 return;
8203 }
8204 match &mut self.state.storage_ui_mode {
8205 StorageUiMode::StorePick { index }
8206 | StorageUiMode::TakePick { index }
8207 | StorageUiMode::ShipPick { index, .. } => {
8208 *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8209 }
8210 StorageUiMode::Menu
8211 | StorageUiMode::StoreAmount { .. }
8212 | StorageUiMode::TakeAmount { .. }
8213 | StorageUiMode::ShipAmount { .. } => {}
8214 }
8215 }
8216
8217 pub fn storage_ui_back(&mut self) {
8218 self.state.storage_ui_mode = match &self.state.storage_ui_mode {
8219 StorageUiMode::StoreAmount { pick_index, .. } => StorageUiMode::StorePick {
8220 index: *pick_index,
8221 },
8222 StorageUiMode::TakeAmount { pick_index, .. } => StorageUiMode::TakePick {
8223 index: *pick_index,
8224 },
8225 StorageUiMode::ShipAmount {
8226 dest_building_id,
8227 dest_label,
8228 pick_index,
8229 ..
8230 } => StorageUiMode::ShipPick {
8231 dest_building_id: dest_building_id.clone(),
8232 dest_label: dest_label.clone(),
8233 index: *pick_index,
8234 },
8235 StorageUiMode::StorePick { .. }
8236 | StorageUiMode::TakePick { .. }
8237 | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
8238 StorageUiMode::Menu => StorageUiMode::Menu,
8239 };
8240 }
8241
8242 pub fn storage_amount_append_char(&mut self, c: char) {
8243 match &mut self.state.storage_ui_mode {
8244 StorageUiMode::StoreAmount { input, .. }
8245 | StorageUiMode::TakeAmount { input, .. }
8246 | StorageUiMode::ShipAmount { input, .. } => {
8247 if c.is_ascii_digit() && input.len() < 8 {
8248 input.push(c);
8249 }
8250 }
8251 _ => {}
8252 }
8253 }
8254
8255 pub fn storage_amount_backspace(&mut self) {
8256 match &mut self.state.storage_ui_mode {
8257 StorageUiMode::StoreAmount { input, .. }
8258 | StorageUiMode::TakeAmount { input, .. }
8259 | StorageUiMode::ShipAmount { input, .. } => {
8260 input.pop();
8261 }
8262 _ => {}
8263 }
8264 }
8265
8266 pub fn storage_ui_typing(&self) -> bool {
8267 matches!(
8268 self.state.storage_ui_mode,
8269 StorageUiMode::StoreAmount { .. }
8270 | StorageUiMode::TakeAmount { .. }
8271 | StorageUiMode::ShipAmount { .. }
8272 )
8273 }
8274
8275 pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
8276 match self.state.storage_ui_mode.clone() {
8277 StorageUiMode::Menu => {
8278 let index = self.state.storage_menu_index;
8279 match index {
8280 0 => {
8281 let opts = self.state.storage_store_options();
8282 if opts.is_empty() {
8283 self.state.push_log("Nothing loose to store.");
8284 return Ok(());
8285 }
8286 self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
8287 }
8288 1 => {
8289 let opts = self.state.storage_vault_options();
8290 if opts.is_empty() {
8291 self.state.push_log("Vault is empty.");
8292 return Ok(());
8293 }
8294 self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
8295 }
8296 n => {
8297 let dest = self
8298 .state
8299 .storage_panel
8300 .as_ref()
8301 .and_then(|p| p.ship_destinations.get(n - 2))
8302 .cloned();
8303 let Some(dest) = dest else {
8304 return Ok(());
8305 };
8306 let opts = self.state.storage_vault_options();
8307 if opts.is_empty() {
8308 self.state
8309 .push_log("Vault is empty — nothing to ship.");
8310 return Ok(());
8311 }
8312 self.state.storage_ui_mode = StorageUiMode::ShipPick {
8313 dest_building_id: dest.building_id,
8314 dest_label: dest.label,
8315 index: 0,
8316 };
8317 }
8318 }
8319 }
8320 StorageUiMode::StorePick { index } => {
8321 let opts = self.state.storage_store_options();
8322 let Some(opt) = opts.get(index) else {
8323 self.state.push_log("Nothing loose to store.");
8324 self.state.storage_ui_mode = StorageUiMode::Menu;
8325 return Ok(());
8326 };
8327 self.state.storage_ui_mode = StorageUiMode::StoreAmount {
8328 pick_index: index,
8329 item_instance_id: opt.item_instance_id,
8330 label: opt.label.clone(),
8331 max_qty: opt.quantity.max(1),
8332 input: String::new(),
8333 };
8334 }
8335 StorageUiMode::TakePick { index } => {
8336 let opts = self.state.storage_vault_options();
8337 let Some(opt) = opts.get(index) else {
8338 self.state.push_log("Vault is empty.");
8339 self.state.storage_ui_mode = StorageUiMode::Menu;
8340 return Ok(());
8341 };
8342 self.state.storage_ui_mode = StorageUiMode::TakeAmount {
8343 pick_index: index,
8344 item_instance_id: opt.item_instance_id,
8345 label: opt.label.clone(),
8346 max_qty: opt.quantity.max(1),
8347 input: String::new(),
8348 };
8349 }
8350 StorageUiMode::ShipPick {
8351 dest_building_id,
8352 dest_label,
8353 index,
8354 } => {
8355 let opts = self.state.storage_vault_options();
8356 let Some(opt) = opts.get(index) else {
8357 self.state
8358 .push_log("Vault is empty — nothing to ship.");
8359 self.state.storage_ui_mode = StorageUiMode::Menu;
8360 return Ok(());
8361 };
8362 self.state.storage_ui_mode = StorageUiMode::ShipAmount {
8363 dest_building_id,
8364 dest_label,
8365 pick_index: index,
8366 item_instance_id: opt.item_instance_id,
8367 label: opt.label.clone(),
8368 max_qty: opt.quantity.max(1),
8369 input: String::new(),
8370 };
8371 }
8372 StorageUiMode::StoreAmount {
8373 item_instance_id,
8374 max_qty,
8375 input,
8376 ..
8377 } => {
8378 let Some(qty) = parse_storage_quantity(&input) else {
8379 self.state
8380 .push_log("Enter a quantity (blank or 0 = all).");
8381 return Ok(());
8382 };
8383 let qty = qty.map(|n| n.min(max_qty).max(1));
8384 self.storage_store(item_instance_id, qty).await?;
8385 self.state.storage_ui_mode = StorageUiMode::Menu;
8386 }
8387 StorageUiMode::TakeAmount {
8388 item_instance_id,
8389 max_qty,
8390 input,
8391 ..
8392 } => {
8393 let Some(qty) = parse_storage_quantity(&input) else {
8394 self.state
8395 .push_log("Enter a quantity (blank or 0 = all).");
8396 return Ok(());
8397 };
8398 let qty = qty.map(|n| n.min(max_qty).max(1));
8399 self.storage_take(item_instance_id, qty).await?;
8400 self.state.storage_ui_mode = StorageUiMode::Menu;
8401 }
8402 StorageUiMode::ShipAmount {
8403 dest_building_id,
8404 item_instance_id,
8405 max_qty,
8406 input,
8407 ..
8408 } => {
8409 let Some(qty) = parse_storage_quantity(&input) else {
8410 self.state
8411 .push_log("Enter a quantity (blank or 0 = all).");
8412 return Ok(());
8413 };
8414 let qty = qty.map(|n| n.min(max_qty).max(1));
8415 self.storage_ship(dest_building_id, item_instance_id, qty)
8416 .await?;
8417 self.state.storage_ui_mode = StorageUiMode::Menu;
8418 }
8419 }
8420 Ok(())
8421 }
8422
8423 pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
8424 match self.state.bank_ui_mode.clone() {
8425 BankUiMode::Menu => {
8426 let choice = self
8427 .state
8428 .bank_menu_options()
8429 .get(self.state.bank_menu_index)
8430 .copied()
8431 .unwrap_or("Deposit…");
8432 match choice {
8433 "Withdraw…" => {
8434 self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
8435 input: String::new(),
8436 };
8437 }
8438 "Deposit all" => self.bank_deposit(0).await?,
8439 "Withdraw all" => self.bank_withdraw(0).await?,
8440 "Transfer…" => {
8441 self.state.bank_ui_mode = BankUiMode::TransferName {
8442 input: String::new(),
8443 };
8444 }
8445 _ => {
8446 self.state.bank_ui_mode = BankUiMode::DepositAmount {
8447 input: String::new(),
8448 };
8449 }
8450 }
8451 }
8452 BankUiMode::DepositAmount { input } => {
8453 let Some(amount) = parse_bank_copper_amount(&input) else {
8454 self.state
8455 .push_log("Enter a copper amount (blank or 0 = everything on person).");
8456 return Ok(());
8457 };
8458 self.bank_deposit(amount).await?;
8459 self.state.bank_ui_mode = BankUiMode::Menu;
8460 }
8461 BankUiMode::WithdrawAmount { input } => {
8462 let Some(amount) = parse_bank_copper_amount(&input) else {
8463 self.state
8464 .push_log("Enter a copper amount (blank or 0 = full ledger).");
8465 return Ok(());
8466 };
8467 self.bank_withdraw(amount).await?;
8468 self.state.bank_ui_mode = BankUiMode::Menu;
8469 }
8470 BankUiMode::TransferName { input } => {
8471 let name = input.trim().to_string();
8472 if name.is_empty() {
8473 self.state.push_log("Enter the recipient character name.");
8474 return Ok(());
8475 }
8476 self.state.bank_ui_mode = BankUiMode::TransferAmount {
8477 to_name: name,
8478 input: String::new(),
8479 };
8480 }
8481 BankUiMode::TransferAmount { to_name, input } => {
8482 let amount: u64 = match input.trim().parse() {
8483 Ok(v) if v > 0 => v,
8484 _ => {
8485 self.state
8486 .push_log("Enter a positive copper amount to transfer.");
8487 return Ok(());
8488 }
8489 };
8490 self.bank_transfer(None, to_name, amount).await?;
8491 self.state.bank_ui_mode = BankUiMode::Menu;
8492 }
8493 }
8494 Ok(())
8495 }
8496
8497 pub fn bank_transfer_back(&mut self) {
8498 match &self.state.bank_ui_mode {
8499 BankUiMode::TransferAmount { to_name, .. } => {
8500 self.state.bank_ui_mode = BankUiMode::TransferName {
8501 input: to_name.clone(),
8502 };
8503 }
8504 BankUiMode::TransferName { .. }
8505 | BankUiMode::DepositAmount { .. }
8506 | BankUiMode::WithdrawAmount { .. } => {
8507 self.state.bank_ui_mode = BankUiMode::Menu;
8508 }
8509 BankUiMode::Menu => {}
8510 }
8511 }
8512
8513 pub fn bank_transfer_append_char(&mut self, c: char) {
8514 match &mut self.state.bank_ui_mode {
8515 BankUiMode::TransferName { input } => {
8516 if input.len() < 32 && !c.is_control() {
8517 input.push(c);
8518 }
8519 }
8520 BankUiMode::DepositAmount { input }
8521 | BankUiMode::WithdrawAmount { input }
8522 | BankUiMode::TransferAmount { input, .. } => {
8523 if c.is_ascii_digit() && input.len() < 12 {
8524 input.push(c);
8525 }
8526 }
8527 BankUiMode::Menu => {}
8528 }
8529 }
8530
8531 pub fn bank_transfer_backspace(&mut self) {
8532 match &mut self.state.bank_ui_mode {
8533 BankUiMode::TransferName { input }
8534 | BankUiMode::DepositAmount { input }
8535 | BankUiMode::WithdrawAmount { input }
8536 | BankUiMode::TransferAmount { input, .. } => {
8537 input.pop();
8538 }
8539 BankUiMode::Menu => {}
8540 }
8541 }
8542
8543 pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
8544 let npc_id = self
8545 .state
8546 .bank_panel
8547 .as_ref()
8548 .map(|p| p.npc_id.clone());
8549 self.state.clear_bank_panel();
8550 if let Some(npc_id) = npc_id {
8551 self.seq += 1;
8552 self.session
8553 .submit_intent(Intent::BankClose {
8554 entity_id: self.state.entity_id,
8555 npc_id,
8556 seq: self.seq,
8557 })
8558 .await?;
8559 self.state.intents_sent += 1;
8560 }
8561 Ok(())
8562 }
8563
8564 pub async fn storage_store(
8565 &mut self,
8566 item_instance_id: uuid::Uuid,
8567 quantity: Option<u32>,
8568 ) -> anyhow::Result<()> {
8569 let Some(panel) = self.state.storage_panel.clone() else {
8570 return Ok(());
8571 };
8572 self.seq += 1;
8573 self.session
8574 .submit_intent(Intent::StorageStore {
8575 entity_id: self.state.entity_id,
8576 npc_id: panel.npc_id,
8577 item_instance_id,
8578 quantity,
8579 seq: self.seq,
8580 })
8581 .await?;
8582 self.state.intents_sent += 1;
8583 Ok(())
8584 }
8585
8586 pub async fn storage_take(
8587 &mut self,
8588 item_instance_id: uuid::Uuid,
8589 quantity: Option<u32>,
8590 ) -> anyhow::Result<()> {
8591 let Some(panel) = self.state.storage_panel.clone() else {
8592 return Ok(());
8593 };
8594 self.seq += 1;
8595 self.session
8596 .submit_intent(Intent::StorageTake {
8597 entity_id: self.state.entity_id,
8598 npc_id: panel.npc_id,
8599 item_instance_id,
8600 quantity,
8601 seq: self.seq,
8602 })
8603 .await?;
8604 self.state.intents_sent += 1;
8605 Ok(())
8606 }
8607
8608 pub async fn storage_ship(
8609 &mut self,
8610 dest_building_id: String,
8611 item_instance_id: uuid::Uuid,
8612 quantity: Option<u32>,
8613 ) -> anyhow::Result<()> {
8614 let Some(panel) = self.state.storage_panel.clone() else {
8615 return Ok(());
8616 };
8617 self.seq += 1;
8618 self.session
8619 .submit_intent(Intent::StorageShip {
8620 entity_id: self.state.entity_id,
8621 npc_id: panel.npc_id,
8622 dest_building_id,
8623 item_instance_id,
8624 quantity,
8625 seq: self.seq,
8626 })
8627 .await?;
8628 self.state.intents_sent += 1;
8629 Ok(())
8630 }
8631
8632 pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
8633 let npc_id = self
8634 .state
8635 .storage_panel
8636 .as_ref()
8637 .map(|p| p.npc_id.clone());
8638 self.state.clear_storage_panel();
8639 if let Some(npc_id) = npc_id {
8640 self.seq += 1;
8641 self.session
8642 .submit_intent(Intent::StorageClose {
8643 entity_id: self.state.entity_id,
8644 npc_id,
8645 seq: self.seq,
8646 })
8647 .await?;
8648 self.state.intents_sent += 1;
8649 }
8650 Ok(())
8651 }
8652
8653 pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
8654 let npc_id = self
8655 .state
8656 .market_panel
8657 .as_ref()
8658 .map(|p| p.npc_id.clone());
8659 self.state.clear_market_panel();
8660 if let Some(npc_id) = npc_id {
8661 self.seq += 1;
8662 self.session
8663 .submit_intent(Intent::MarketClose {
8664 entity_id: self.state.entity_id,
8665 npc_id,
8666 seq: self.seq,
8667 })
8668 .await?;
8669 self.state.intents_sent += 1;
8670 }
8671 Ok(())
8672 }
8673
8674 pub fn market_move_selection(&mut self, delta: i32) {
8675 let indices = self.state.market_filtered_listing_indices();
8676 let n = indices.len();
8677 if n == 0 {
8678 self.state.market_menu_index = 0;
8679 return;
8680 }
8681 let cur = self.state.market_menu_index as i32;
8682 self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
8683 }
8684
8685 pub fn market_page_selection(&mut self, pages: i32) {
8686 let indices = self.state.market_filtered_listing_indices();
8687 let n = indices.len();
8688 if n == 0 {
8689 self.state.market_menu_index = 0;
8690 return;
8691 }
8692 self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
8693 }
8694
8695 pub fn market_list_page(&mut self, pages: i32) {
8696 match &self.state.market_ui_mode {
8697 MarketUiMode::ListSource { index } => {
8698 let n = self.state.market_list_source_options().len();
8699 if n == 0 {
8700 return;
8701 }
8702 let next = page_list_index(*index, pages, n);
8703 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
8704 }
8705 MarketUiMode::ListPick { source, index } => {
8706 let opts = self.state.market_list_item_options(source);
8707 let n = opts.len();
8708 if n == 0 {
8709 return;
8710 }
8711 let next = page_list_index(*index, pages, n);
8712 self.state.market_ui_mode = MarketUiMode::ListPick {
8713 source: source.clone(),
8714 index: next,
8715 };
8716 }
8717 _ => {}
8718 }
8719 }
8720
8721 pub fn market_cycle_category(&mut self, delta: i32) {
8722 let groups = self.state.market_available_category_groups();
8723 let mut labels: Vec<Option<&'static str>> = vec![None];
8725 labels.extend(groups.into_iter().map(Some));
8726 let n = labels.len() as i32;
8727 let cur = labels
8728 .iter()
8729 .position(|g| *g == self.state.market_category_filter)
8730 .unwrap_or(0) as i32;
8731 let next = (cur + delta).rem_euclid(n) as usize;
8732 self.state.market_category_filter = labels[next];
8733 self.state.market_menu_index = 0;
8734 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8735 let source = source.clone();
8736 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8737 }
8738 }
8739
8740 pub fn focus_market_filter(&mut self) {
8741 self.state.market_filter_focused = true;
8742 }
8743
8744 pub fn append_market_filter_char(&mut self, ch: char) {
8745 if !self.state.market_filter_focused {
8746 return;
8747 }
8748 if ch.is_control() {
8749 return;
8750 }
8751 if self.state.market_filter.len() < 48 {
8752 self.state.market_filter.push(ch);
8753 self.state.market_menu_index = 0;
8754 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8755 let source = source.clone();
8756 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8757 }
8758 }
8759 }
8760
8761 pub fn market_filter_backspace(&mut self) {
8762 if !self.state.market_filter_focused {
8763 return;
8764 }
8765 self.state.market_filter.pop();
8766 self.state.market_menu_index = 0;
8767 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8768 let source = source.clone();
8769 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8770 }
8771 }
8772
8773 pub fn clear_or_blur_market_filter(&mut self) -> bool {
8775 if self.state.market_filter_focused {
8776 if !self.state.market_filter.is_empty() {
8777 self.state.market_filter.clear();
8778 self.state.market_menu_index = 0;
8779 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8780 let source = source.clone();
8781 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8782 }
8783 return true;
8784 }
8785 self.state.market_filter_focused = false;
8786 return true;
8787 }
8788 if !self.state.market_filter.is_empty() {
8789 self.state.market_filter.clear();
8790 self.state.market_menu_index = 0;
8791 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8792 let source = source.clone();
8793 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8794 }
8795 return true;
8796 }
8797 false
8798 }
8799
8800 pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
8801 if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
8802 return self.market_confirm_buy(listing_id, qty).await;
8803 }
8804 let Some(panel) = self.state.market_panel.clone() else {
8805 return Ok(());
8806 };
8807 let indices = self.state.market_filtered_listing_indices();
8808 let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
8809 return Ok(());
8810 };
8811 let Some(listing) = panel.listings.get(raw_idx) else {
8812 return Ok(());
8813 };
8814 if listing.mine {
8815 self.seq += 1;
8816 self.session
8817 .submit_intent(Intent::MarketDelist {
8818 entity_id: self.state.entity_id,
8819 npc_id: panel.npc_id.clone(),
8820 listing_id: listing.listing_id,
8821 dest: flatland_protocol::GoodsLocation::Person,
8822 seq: self.seq,
8823 })
8824 .await?;
8825 self.state.intents_sent += 1;
8826 return Ok(());
8827 }
8828 let qty = 1u32.min(listing.quantity).max(1);
8829 let line = listing.unit_price_copper.saturating_mul(qty as u64);
8830 self.state.market_buy_confirm = Some((
8831 listing.listing_id,
8832 qty,
8833 listing.unit_price_copper,
8834 line,
8835 listing.display_name.clone(),
8836 ));
8837 Ok(())
8838 }
8839
8840 pub async fn market_confirm_buy(
8841 &mut self,
8842 listing_id: uuid::Uuid,
8843 quantity: u32,
8844 ) -> anyhow::Result<()> {
8845 let Some(panel) = self.state.market_panel.clone() else {
8846 self.state.market_buy_confirm = None;
8847 return Ok(());
8848 };
8849 self.state.market_buy_confirm = None;
8850 self.seq += 1;
8851 self.session
8852 .submit_intent(Intent::MarketBuy {
8853 entity_id: self.state.entity_id,
8854 npc_id: panel.npc_id,
8855 listing_id,
8856 quantity,
8857 dest: flatland_protocol::GoodsLocation::Person,
8858 seq: self.seq,
8859 })
8860 .await?;
8861 self.state.intents_sent += 1;
8862 Ok(())
8863 }
8864
8865 pub fn market_begin_list(&mut self) {
8867 if self.state.market_panel.is_none() {
8868 return;
8869 }
8870 let sources = self.state.market_list_source_options();
8871 if sources.is_empty() {
8872 self.state.push_log("Nothing to list from.");
8873 return;
8874 }
8875 if sources.len() == 1 {
8877 let (source, _) = sources[0].clone();
8878 let opts = self.state.market_list_item_options(&source);
8879 if opts.is_empty() {
8880 self.state.push_log("Nothing loose to list.");
8881 return;
8882 }
8883 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8884 self.state.market_buy_confirm = None;
8885 return;
8886 }
8887 self.state.market_buy_confirm = None;
8888 self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
8889 }
8890
8891 pub fn market_ui_back(&mut self) {
8892 self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
8893 MarketUiMode::Browse => MarketUiMode::Browse,
8894 MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
8895 MarketUiMode::ListPick { .. } => {
8896 if self.state.market_list_source_options().len() <= 1 {
8897 MarketUiMode::Browse
8898 } else {
8899 MarketUiMode::ListSource { index: 0 }
8900 }
8901 }
8902 MarketUiMode::ListAmount {
8903 source,
8904 pick_index,
8905 ..
8906 } => MarketUiMode::ListPick {
8907 source,
8908 index: pick_index,
8909 },
8910 MarketUiMode::ListPrice {
8911 source,
8912 item_instance_id,
8913 label,
8914 max_qty,
8915 quantity,
8916 ..
8917 } => {
8918 let input = quantity
8919 .map(|q| q.to_string())
8920 .unwrap_or_default();
8921 MarketUiMode::ListAmount {
8922 source,
8923 pick_index: 0,
8924 item_instance_id,
8925 label,
8926 max_qty,
8927 input,
8928 }
8929 }
8930 };
8931 }
8932
8933 pub fn market_list_move(&mut self, delta: i32) {
8934 match &self.state.market_ui_mode {
8935 MarketUiMode::ListSource { index } => {
8936 let n = self.state.market_list_source_options().len();
8937 if n == 0 {
8938 return;
8939 }
8940 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8941 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
8942 }
8943 MarketUiMode::ListPick { source, index } => {
8944 let opts = self.state.market_list_item_options(source);
8945 let n = opts.len();
8946 if n == 0 {
8947 return;
8948 }
8949 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8950 self.state.market_ui_mode = MarketUiMode::ListPick {
8951 source: source.clone(),
8952 index: next,
8953 };
8954 }
8955 _ => {}
8956 }
8957 }
8958
8959 pub fn market_list_amount_append_char(&mut self, c: char) {
8960 if !c.is_ascii_digit() {
8961 return;
8962 }
8963 match &mut self.state.market_ui_mode {
8964 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
8965 if input.len() < 12 {
8966 input.push(c);
8967 }
8968 }
8969 _ => {}
8970 }
8971 }
8972
8973 pub fn market_list_amount_backspace(&mut self) {
8974 match &mut self.state.market_ui_mode {
8975 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
8976 input.pop();
8977 }
8978 _ => {}
8979 }
8980 }
8981
8982 pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
8983 match self.state.market_ui_mode.clone() {
8984 MarketUiMode::Browse => Ok(()),
8985 MarketUiMode::ListSource { index } => {
8986 let sources = self.state.market_list_source_options();
8987 let Some((source, _)) = sources.get(index).cloned() else {
8988 return Ok(());
8989 };
8990 let opts = self.state.market_list_item_options(&source);
8991 if opts.is_empty() {
8992 self.state.push_log("Nothing to list from that source.");
8993 return Ok(());
8994 }
8995 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8996 Ok(())
8997 }
8998 MarketUiMode::ListPick { source, index } => {
8999 let opts = self.state.market_list_item_options(&source);
9000 let Some(opt) = opts.get(index) else {
9001 self.state.push_log("Nothing to list.");
9002 self.state.market_ui_mode = MarketUiMode::Browse;
9003 return Ok(());
9004 };
9005 self.state.market_ui_mode = MarketUiMode::ListAmount {
9006 source,
9007 pick_index: index,
9008 item_instance_id: opt.item_instance_id,
9009 label: opt.label.clone(),
9010 max_qty: opt.quantity.max(1),
9011 input: String::new(),
9012 };
9013 Ok(())
9014 }
9015 MarketUiMode::ListAmount {
9016 source,
9017 item_instance_id,
9018 label,
9019 max_qty,
9020 input,
9021 ..
9022 } => {
9023 let Some(qty_opt) = parse_storage_quantity(&input) else {
9024 self.state.push_log("Enter a quantity (blank = all).");
9025 return Ok(());
9026 };
9027 if let Some(q) = qty_opt {
9028 if q > max_qty {
9029 self.state
9030 .push_log(format!("Only {max_qty} available."));
9031 return Ok(());
9032 }
9033 }
9034 self.state.market_ui_mode = MarketUiMode::ListPrice {
9035 source,
9036 item_instance_id,
9037 label,
9038 quantity: qty_opt,
9039 max_qty,
9040 input: String::new(),
9041 };
9042 Ok(())
9043 }
9044 MarketUiMode::ListPrice {
9045 source,
9046 item_instance_id,
9047 label,
9048 quantity,
9049 input,
9050 ..
9051 } => {
9052 let price = input.trim().parse::<u64>().unwrap_or(0);
9053 if price == 0 {
9054 self.state.push_log("Enter a unit price of at least 1 copper.");
9055 return Ok(());
9056 }
9057 let Some(panel) = self.state.market_panel.clone() else {
9058 self.state.market_ui_mode = MarketUiMode::Browse;
9059 return Ok(());
9060 };
9061 let goods = match source {
9062 MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
9063 MarketListSourceKind::TownStorage { building_id } => {
9064 flatland_protocol::GoodsLocation::TownStorage { building_id }
9065 }
9066 };
9067 self.seq += 1;
9068 self.session
9069 .submit_intent(Intent::MarketList {
9070 entity_id: self.state.entity_id,
9071 npc_id: panel.npc_id,
9072 source: goods,
9073 item_instance_id,
9074 quantity,
9075 unit_price_copper: price,
9076 seq: self.seq,
9077 })
9078 .await?;
9079 self.state.intents_sent += 1;
9080 self.state
9081 .push_log(format!("Listing {label} @ {price} cp…"));
9082 self.state.market_ui_mode = MarketUiMode::Browse;
9083 Ok(())
9084 }
9085 }
9086 }
9087
9088 pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
9090 let return_to_verbs = self.state.npc_verb_target.is_some();
9091 self.close_shop_menu().await?;
9092 if return_to_verbs {
9093 self.state.show_npc_verb_menu = true;
9094 }
9095 Ok(())
9096 }
9097
9098 pub fn shop_tab_toggle(&mut self) {
9099 self.state.shop_tab = match self.state.shop_tab {
9100 ShopTab::Buy => ShopTab::Sell,
9101 ShopTab::Sell => ShopTab::Buy,
9102 };
9103 self.state.shop_menu_index = 0;
9104 if self.state.shop_tab == ShopTab::Sell {
9105 self.state.shop_quantity_set_max();
9106 }
9107 self.state.clamp_shop_selection();
9108 }
9109
9110 pub fn shop_menu_move(&mut self, delta: i32) {
9111 self.state.shop_menu_move(delta);
9112 }
9113
9114 pub fn shop_quantity_adjust(&mut self, delta: i32) {
9115 self.state.shop_quantity_adjust(delta);
9116 }
9117
9118 pub fn shop_quantity_set_max(&mut self) {
9119 self.state.shop_quantity_set_max();
9120 }
9121
9122 pub fn toggle_quest_menu(&mut self) {
9123 self.state.show_quest_menu = !self.state.show_quest_menu;
9124 if self.state.show_quest_menu {
9125 self.state.quest_menu_index = 0;
9126 self.state.quest_withdraw_confirm = false;
9127 self.state.show_workers_menu = false;
9128 }
9129 }
9130
9131 pub fn toggle_workers_menu(&mut self) {
9132 if self.state.show_workers_menu {
9133 self.close_workers_menu_ui();
9134 } else {
9135 self.state.show_workers_menu = true;
9136 self.state.workers_menu_index = 0;
9137 self.state.show_quest_menu = false;
9138 self.close_worker_give_picker();
9139 self.close_worker_give_target_picker();
9140 self.close_worker_take_picker();
9141 self.close_worker_teach_picker();
9142 self.cancel_worker_rename();
9143 }
9144 }
9145
9146 pub fn close_workers_menu_ui(&mut self) {
9148 self.state.show_workers_menu = false;
9149 self.close_worker_give_picker();
9150 self.close_worker_give_target_picker();
9151 self.close_worker_take_picker();
9152 self.close_worker_teach_picker();
9153 self.cancel_worker_rename();
9154 }
9155
9156 pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
9158 let Some(idx) = self
9159 .state
9160 .hired_workers
9161 .iter()
9162 .position(|w| w.instance_id == instance_id)
9163 else {
9164 anyhow::bail!("worker not found");
9165 };
9166 let label = self.state.hired_workers[idx].label.clone();
9167 self.state.show_workers_menu = true;
9168 self.state.workers_menu_index = idx;
9169 self.state.show_quest_menu = false;
9170 self.close_worker_give_picker();
9171 self.close_worker_give_target_picker();
9172 self.close_worker_take_picker();
9173 self.close_worker_teach_picker();
9174 self.cancel_worker_rename();
9175 self.set_worker_attending(instance_id, true).await?;
9176 self.state
9177 .push_log(format!("Managing {label} — job paused while menu is open"));
9178 Ok(())
9179 }
9180
9181 pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
9183 self.close_workers_menu_ui();
9184 self.release_worker_attend().await
9185 }
9186
9187 async fn set_worker_attending(
9188 &mut self,
9189 instance_id: &str,
9190 attending: bool,
9191 ) -> anyhow::Result<()> {
9192 if attending {
9193 if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9194 return Ok(());
9195 }
9196 if let Some(prev) = self.state.attending_worker_instance_id.clone() {
9198 if prev != instance_id {
9199 self.send_attend_hired_worker(&prev, false).await?;
9200 }
9201 }
9202 self.send_attend_hired_worker(instance_id, true).await?;
9203 self.state.attending_worker_instance_id = Some(instance_id.to_string());
9204 } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9205 self.send_attend_hired_worker(instance_id, false).await?;
9206 self.state.attending_worker_instance_id = None;
9207 }
9208 Ok(())
9209 }
9210
9211 pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
9212 let Some(id) = self.state.attending_worker_instance_id.take() else {
9213 return Ok(());
9214 };
9215 self.send_attend_hired_worker(&id, false).await
9216 }
9217
9218 async fn send_attend_hired_worker(
9219 &mut self,
9220 worker_instance_id: &str,
9221 attending: bool,
9222 ) -> anyhow::Result<()> {
9223 self.seq += 1;
9224 self.session
9225 .submit_intent(Intent::AttendHiredWorker {
9226 entity_id: self.state.entity_id,
9227 worker_instance_id: worker_instance_id.to_string(),
9228 attending,
9229 seq: self.seq,
9230 })
9231 .await?;
9232 self.state.intents_sent += 1;
9233 Ok(())
9234 }
9235
9236 pub fn workers_menu_move(&mut self, delta: i32) {
9237 let n = self.state.hired_workers.len();
9238 if n == 0 {
9239 return;
9240 }
9241 let idx = self.state.workers_menu_index as i32;
9242 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9243 }
9244
9245 pub fn toggle_workers_menu_compact(&mut self) {
9246 self.state.workers_menu_compact = !self.state.workers_menu_compact;
9247 let mut cfg = crate::client_config::ClientConfig::load();
9248 let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
9249 }
9250
9251 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
9252 let Some(worker) = self
9253 .state
9254 .hired_workers
9255 .get(self.state.workers_menu_index)
9256 .cloned()
9257 else {
9258 anyhow::bail!("no worker selected");
9259 };
9260 self.seq += 1;
9261 self.session
9262 .submit_intent(Intent::DismissWorker {
9263 entity_id: self.state.entity_id,
9264 worker_instance_id: worker.instance_id.clone(),
9265 seq: self.seq,
9266 })
9267 .await?;
9268 self.state.intents_sent += 1;
9269 self.state
9270 .hired_workers
9271 .retain(|w| w.instance_id != worker.instance_id);
9272 if self.state.workers_menu_index >= self.state.hired_workers.len() {
9273 self.state.workers_menu_index = self
9274 .state
9275 .hired_workers
9276 .len()
9277 .saturating_sub(1);
9278 }
9279 self.state.push_log(format!("Dismissed {}", worker.label));
9280 Ok(())
9281 }
9282
9283 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
9284 let Some(worker) = self
9285 .state
9286 .hired_workers
9287 .get(self.state.workers_menu_index)
9288 .cloned()
9289 else {
9290 anyhow::bail!("no worker selected");
9291 };
9292 let mode = match worker.mode {
9293 flatland_protocol::WorkerModeView::Companion => "job_loop",
9294 flatland_protocol::WorkerModeView::JobLoop => "idle",
9295 flatland_protocol::WorkerModeView::Idle => "companion",
9296 };
9297 self.seq += 1;
9298 self.session
9299 .submit_intent(Intent::SetWorkerMode {
9300 entity_id: self.state.entity_id,
9301 worker_instance_id: worker.instance_id,
9302 mode: mode.into(),
9303 seq: self.seq,
9304 })
9305 .await?;
9306 self.state.intents_sent += 1;
9307 Ok(())
9308 }
9309
9310 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
9311 if self.state.hired_workers.is_empty() {
9312 return self.hire_worker_laborer().await;
9313 }
9314 self.workers_toggle_mode_selected().await
9315 }
9316
9317 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
9320 let row = self
9321 .state
9322 .inventory_selected_row()
9323 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
9324 .clone();
9325 if row.from != flatland_protocol::InventoryLocation::Root {
9326 anyhow::bail!("select a carried item to give");
9327 }
9328 let Some(instance_id) = row.stack.item_instance_id else {
9329 anyhow::bail!("that stack can't be given");
9330 };
9331 let options = self.nearby_worker_give_targets();
9332 if options.is_empty() {
9333 anyhow::bail!(
9334 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
9335 );
9336 }
9337 let item_label = row
9338 .stack
9339 .display_name
9340 .as_deref()
9341 .unwrap_or(&row.stack.template_id)
9342 .to_string();
9343 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
9344 item_instance_id: instance_id,
9345 item_label,
9346 quantity: None,
9347 options,
9348 });
9349 self.state.worker_give_target_picker_index = 0;
9350 self.state.show_worker_give_target_picker = true;
9351 self.state.show_inventory_menu = false;
9353 Ok(())
9354 }
9355
9356 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
9358 let (px, py, _) = self.state.player_position_with_z();
9359 let mut options: Vec<WorkerGiveTargetOption> = self
9360 .state
9361 .hired_workers
9362 .iter()
9363 .filter_map(|w| {
9364 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
9365 if dist > WORKER_GIVE_RANGE_M {
9366 return None;
9367 }
9368 Some(WorkerGiveTargetOption {
9369 instance_id: w.instance_id.clone(),
9370 label: w.label.clone(),
9371 distance_m: dist,
9372 })
9373 })
9374 .collect();
9375 options.sort_by(|a, b| {
9376 a.distance_m
9377 .partial_cmp(&b.distance_m)
9378 .unwrap_or(std::cmp::Ordering::Equal)
9379 });
9380 options
9381 }
9382
9383 pub fn close_worker_give_target_picker(&mut self) {
9384 self.state.show_worker_give_target_picker = false;
9385 self.state.worker_give_target_picker = None;
9386 self.state.worker_give_target_picker_index = 0;
9387 }
9388
9389 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
9390 let Some(picker) = &self.state.worker_give_target_picker else {
9391 return;
9392 };
9393 let n = picker.options.len();
9394 if n == 0 {
9395 return;
9396 }
9397 let idx = self.state.worker_give_target_picker_index as i32;
9398 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9399 }
9400
9401 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
9402 let Some(picker) = self.state.worker_give_target_picker.clone() else {
9403 anyhow::bail!("give target picker not open");
9404 };
9405 let Some(opt) = picker
9406 .options
9407 .get(self.state.worker_give_target_picker_index)
9408 .cloned()
9409 else {
9410 anyhow::bail!("no worker selected");
9411 };
9412 let Some(worker) = self
9413 .state
9414 .hired_workers
9415 .iter()
9416 .find(|w| w.instance_id == opt.instance_id)
9417 .cloned()
9418 else {
9419 self.close_worker_give_target_picker();
9420 anyhow::bail!("worker no longer hired");
9421 };
9422 self.give_item_to_worker(
9423 &worker.instance_id,
9424 &worker.label,
9425 worker.x,
9426 worker.y,
9427 picker.item_instance_id,
9428 &picker.item_label,
9429 picker.quantity,
9430 )
9431 .await?;
9432 self.close_worker_give_target_picker();
9433 Ok(())
9434 }
9435
9436 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
9438 self.open_worker_give_target_picker()
9439 }
9440
9441 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
9443 let Some(worker) = self
9444 .state
9445 .hired_workers
9446 .get(self.state.workers_menu_index)
9447 .cloned()
9448 else {
9449 anyhow::bail!("select a hired worker first");
9450 };
9451 let (px, py, _) = self.state.player_position_with_z();
9452 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9453 if dist > WORKER_GIVE_RANGE_M {
9454 anyhow::bail!(
9455 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
9456 worker.label
9457 );
9458 }
9459 let options = self.state.giveable_inventory_options();
9460 if options.is_empty() {
9461 anyhow::bail!("nothing in inventory to give");
9462 }
9463 self.state.worker_give_picker = Some(WorkerGivePicker {
9464 worker_instance_id: worker.instance_id,
9465 worker_label: worker.label,
9466 options,
9467 });
9468 self.state.worker_give_picker_index = 0;
9469 self.state.show_worker_give_picker = true;
9470 Ok(())
9471 }
9472
9473 pub fn close_worker_give_picker(&mut self) {
9474 self.state.show_worker_give_picker = false;
9475 self.state.worker_give_picker = None;
9476 self.state.worker_give_picker_index = 0;
9477 }
9478
9479 pub fn worker_give_picker_move(&mut self, delta: i32) {
9480 let Some(picker) = &self.state.worker_give_picker else {
9481 return;
9482 };
9483 let n = picker.options.len();
9484 if n == 0 {
9485 return;
9486 }
9487 let idx = self.state.worker_give_picker_index as i32;
9488 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9489 }
9490
9491 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
9493 let Some(picker) = self.state.worker_give_picker.clone() else {
9494 anyhow::bail!("give picker not open");
9495 };
9496 let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
9497 anyhow::bail!("no item selected");
9498 };
9499 let Some(worker) = self
9500 .state
9501 .hired_workers
9502 .iter()
9503 .find(|w| w.instance_id == picker.worker_instance_id)
9504 .cloned()
9505 else {
9506 self.close_worker_give_picker();
9507 anyhow::bail!("worker no longer hired");
9508 };
9509 self.give_item_to_worker(
9510 &worker.instance_id,
9511 &worker.label,
9512 worker.x,
9513 worker.y,
9514 opt.item_instance_id,
9515 &opt.label,
9516 None,
9517 )
9518 .await?;
9519 let options = self.state.giveable_inventory_options();
9521 if options.is_empty() {
9522 self.close_worker_give_picker();
9523 } else {
9524 self.state.worker_give_picker = Some(WorkerGivePicker {
9525 worker_instance_id: picker.worker_instance_id,
9526 worker_label: picker.worker_label,
9527 options,
9528 });
9529 if self.state.worker_give_picker_index
9530 >= self
9531 .state
9532 .worker_give_picker
9533 .as_ref()
9534 .map(|p| p.options.len())
9535 .unwrap_or(0)
9536 {
9537 self.state.worker_give_picker_index = self
9538 .state
9539 .worker_give_picker
9540 .as_ref()
9541 .map(|p| p.options.len().saturating_sub(1))
9542 .unwrap_or(0);
9543 }
9544 }
9545 Ok(())
9546 }
9547
9548 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
9550 let Some(worker) = self
9551 .state
9552 .hired_workers
9553 .get(self.state.workers_menu_index)
9554 .cloned()
9555 else {
9556 anyhow::bail!("select a hired worker first");
9557 };
9558 let (px, py, _) = self.state.player_position_with_z();
9559 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9560 if dist > WORKER_GIVE_RANGE_M {
9561 anyhow::bail!(
9562 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
9563 worker.label
9564 );
9565 }
9566 let options = self.state.teachable_blueprint_options(&worker);
9567 if options.is_empty() {
9568 anyhow::bail!("no recipes you know that {} still needs", worker.label);
9569 }
9570 self.state.worker_teach_picker = Some(WorkerTeachPicker {
9571 worker_instance_id: worker.instance_id,
9572 worker_label: worker.label,
9573 worker_level: worker.level,
9574 options,
9575 });
9576 self.state.worker_teach_picker_index = 0;
9577 self.state.show_worker_teach_picker = true;
9578 Ok(())
9579 }
9580
9581 pub fn close_worker_teach_picker(&mut self) {
9582 self.state.show_worker_teach_picker = false;
9583 self.state.worker_teach_picker = None;
9584 self.state.worker_teach_picker_index = 0;
9585 }
9586
9587 pub fn worker_teach_picker_move(&mut self, delta: i32) {
9588 let Some(picker) = &self.state.worker_teach_picker else {
9589 return;
9590 };
9591 let n = picker.options.len();
9592 if n == 0 {
9593 return;
9594 }
9595 let idx = self.state.worker_teach_picker_index as i32;
9596 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9597 }
9598
9599 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
9600 let Some(picker) = self.state.worker_teach_picker.clone() else {
9601 anyhow::bail!("teach picker not open");
9602 };
9603 let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
9604 anyhow::bail!("nothing selected");
9605 };
9606 if !opt.level_ok {
9607 anyhow::bail!(
9608 "{} needs level {} (is level {})",
9609 picker.worker_label,
9610 opt.min_level,
9611 opt.worker_level
9612 );
9613 }
9614 if !opt.can_afford {
9615 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
9616 }
9617 let Some(worker) = self
9618 .state
9619 .hired_workers
9620 .iter()
9621 .find(|w| w.instance_id == picker.worker_instance_id)
9622 .cloned()
9623 else {
9624 anyhow::bail!("worker gone");
9625 };
9626 let (px, py, _) = self.state.player_position_with_z();
9627 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9628 if dist > WORKER_GIVE_RANGE_M {
9629 anyhow::bail!("worker {} too far — stand next to them", worker.label);
9630 }
9631 self.seq += 1;
9632 self.session
9633 .submit_intent(Intent::TeachWorkerBlueprint {
9634 entity_id: self.state.entity_id,
9635 worker_instance_id: picker.worker_instance_id.clone(),
9636 blueprint_id: opt.blueprint_id.clone(),
9637 seq: self.seq,
9638 })
9639 .await?;
9640 self.state.intents_sent += 1;
9641 self.state.push_log(format!(
9642 "Teaching {} to {} ({} cp)",
9643 opt.label, picker.worker_label, opt.cost_copper
9644 ));
9645 self.close_worker_teach_picker();
9646 Ok(())
9647 }
9648
9649 async fn give_item_to_worker(
9650 &mut self,
9651 worker_instance_id: &str,
9652 worker_label: &str,
9653 worker_x: f32,
9654 worker_y: f32,
9655 item_instance_id: uuid::Uuid,
9656 item_label: &str,
9657 quantity: Option<u32>,
9658 ) -> anyhow::Result<()> {
9659 let (px, py, _) = self.state.player_position_with_z();
9660 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
9661 if dist > WORKER_GIVE_RANGE_M {
9662 anyhow::bail!("worker {worker_label} too far — stand next to them");
9663 }
9664 self.seq += 1;
9665 self.session
9666 .submit_intent(Intent::GiveWorkerItem {
9667 entity_id: self.state.entity_id,
9668 worker_instance_id: worker_instance_id.to_string(),
9669 item_instance_id,
9670 quantity,
9671 seq: self.seq,
9672 })
9673 .await?;
9674 self.state.intents_sent += 1;
9675 self.state
9676 .push_log(format!("Gave {item_label} to {worker_label}"));
9677 Ok(())
9678 }
9679
9680 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
9682 let Some(worker) = self
9683 .state
9684 .hired_workers
9685 .get(self.state.workers_menu_index)
9686 .cloned()
9687 else {
9688 anyhow::bail!("select a hired worker first");
9689 };
9690 let (px, py, _) = self.state.player_position_with_z();
9691 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9692 if dist > WORKER_GIVE_RANGE_M {
9693 anyhow::bail!(
9694 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
9695 worker.label
9696 );
9697 }
9698 let options = Self::worker_inventory_options(&worker);
9699 if options.is_empty() {
9700 anyhow::bail!("{} isn't carrying anything", worker.label);
9701 }
9702 let initial_qty = options
9703 .first()
9704 .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
9705 .unwrap_or(1);
9706 self.state.worker_take_picker = Some(WorkerTakePicker {
9707 worker_instance_id: worker.instance_id,
9708 worker_label: worker.label,
9709 options,
9710 quantity: initial_qty,
9711 });
9712 self.state.worker_take_picker_index = 0;
9713 self.state.show_worker_take_picker = true;
9714 Ok(())
9715 }
9716
9717 fn worker_inventory_options(
9718 worker: &flatland_protocol::HiredWorkerView,
9719 ) -> Vec<WorkerGiveOption> {
9720 worker
9721 .inventory
9722 .iter()
9723 .filter_map(|stack| {
9724 let item_instance_id = stack.item_instance_id?;
9725 let label = stack
9726 .display_name
9727 .clone()
9728 .unwrap_or_else(|| stack.template_id.clone());
9729 let label = if stack.quantity > 1 {
9730 format!("{label} ×{}", stack.quantity)
9731 } else {
9732 label
9733 };
9734 Some(WorkerGiveOption {
9735 item_instance_id,
9736 label,
9737 quantity: stack.quantity,
9738 template_id: stack.template_id.clone(),
9739 })
9740 })
9741 .collect()
9742 }
9743
9744 pub fn close_worker_take_picker(&mut self) {
9745 self.state.show_worker_take_picker = false;
9746 self.state.worker_take_picker = None;
9747 self.state.worker_take_picker_index = 0;
9748 }
9749
9750 pub fn worker_take_picker_move(&mut self, delta: i32) {
9751 let Some(picker) = &self.state.worker_take_picker else {
9752 return;
9753 };
9754 let n = picker.options.len();
9755 if n == 0 {
9756 return;
9757 }
9758 let idx = self.state.worker_take_picker_index as i32;
9759 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9760 self.clamp_worker_take_quantity();
9761 }
9762
9763 pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
9764 let Some(picker) = &mut self.state.worker_take_picker else {
9765 return;
9766 };
9767 let max = picker
9768 .options
9769 .get(self.state.worker_take_picker_index)
9770 .map(|o| o.quantity.max(1))
9771 .unwrap_or(1);
9772 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
9773 picker.quantity = next as u32;
9774 }
9775
9776 pub fn worker_take_picker_set_quantity_max(&mut self) {
9777 let Some(picker) = &mut self.state.worker_take_picker else {
9778 return;
9779 };
9780 let max = picker
9781 .options
9782 .get(self.state.worker_take_picker_index)
9783 .map(|o| o.quantity.max(1))
9784 .unwrap_or(1);
9785 picker.quantity = max;
9786 }
9787
9788 fn clamp_worker_take_quantity(&mut self) {
9789 let Some(picker) = &mut self.state.worker_take_picker else {
9790 return;
9791 };
9792 let max = picker
9793 .options
9794 .get(self.state.worker_take_picker_index)
9795 .map(|o| o.quantity.max(1))
9796 .unwrap_or(1);
9797 if picker.quantity == 0 || picker.quantity > max {
9798 picker.quantity = if max > 1 { 1 } else { max };
9799 }
9800 }
9801
9802 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
9803 let Some(picker) = self.state.worker_take_picker.clone() else {
9804 anyhow::bail!("take picker not open");
9805 };
9806 let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
9807 anyhow::bail!("no item selected");
9808 };
9809 let Some(worker) = self
9810 .state
9811 .hired_workers
9812 .iter()
9813 .find(|w| w.instance_id == picker.worker_instance_id)
9814 .cloned()
9815 else {
9816 self.close_worker_take_picker();
9817 anyhow::bail!("worker no longer hired");
9818 };
9819 let qty = picker.quantity.clamp(1, opt.quantity.max(1));
9820 let intent_qty = if qty >= opt.quantity {
9821 None
9822 } else {
9823 Some(qty)
9824 };
9825 self.take_item_from_worker(
9826 &worker.instance_id,
9827 &worker.label,
9828 worker.x,
9829 worker.y,
9830 opt.item_instance_id,
9831 &opt.label,
9832 intent_qty,
9833 )
9834 .await?;
9835 Ok(())
9838 }
9839
9840 async fn take_item_from_worker(
9841 &mut self,
9842 worker_instance_id: &str,
9843 worker_label: &str,
9844 worker_x: f32,
9845 worker_y: f32,
9846 item_instance_id: uuid::Uuid,
9847 item_label: &str,
9848 quantity: Option<u32>,
9849 ) -> anyhow::Result<()> {
9850 let (px, py, _) = self.state.player_position_with_z();
9851 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
9852 if dist > WORKER_GIVE_RANGE_M {
9853 anyhow::bail!("worker {worker_label} too far — stand next to them");
9854 }
9855 self.seq += 1;
9856 self.session
9857 .submit_intent(Intent::TakeWorkerItem {
9858 entity_id: self.state.entity_id,
9859 worker_instance_id: worker_instance_id.to_string(),
9860 item_instance_id,
9861 quantity,
9862 seq: self.seq,
9863 })
9864 .await?;
9865 self.state.intents_sent += 1;
9866 let qty_note = quantity
9867 .map(|q| format!(" ×{q}"))
9868 .unwrap_or_default();
9869 self.state
9870 .push_log(format!("Taking {item_label}{qty_note} from {worker_label}…"));
9871 Ok(())
9872 }
9873
9874 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
9875 if !self.state.has_worker_lodging() {
9876 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
9877 }
9878 self.seq += 1;
9879 self.session
9880 .submit_intent(Intent::HireWorker {
9881 entity_id: self.state.entity_id,
9882 def_id: "worker_laborer".into(),
9883 wage_copper_per_interval: 8,
9884 lodging_container_id: None,
9885 job_yaml: None,
9886 seq: self.seq,
9887 })
9888 .await?;
9889 self.state.intents_sent += 1;
9890 Ok(())
9891 }
9892
9893 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
9894 let Some(worker) = self
9895 .state
9896 .hired_workers
9897 .get(self.state.workers_menu_index)
9898 .cloned()
9899 else {
9900 anyhow::bail!("select a hired worker first");
9901 };
9902 let lodging = worker.lodging_container_id.clone().or_else(|| {
9903 crate::worker_route_editor::owned_lodging_container_ids(
9904 &self.state.placed_containers,
9905 self.state.character_id,
9906 )
9907 .into_iter()
9908 .next()
9909 .map(|(id, _)| id)
9910 });
9911 let label = worker.label.clone();
9912 let editor = if let Some(route) = &worker.route {
9913 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
9914 worker.instance_id,
9915 worker.label,
9916 route,
9917 lodging,
9918 )
9919 } else {
9920 crate::worker_route_editor::WorkerRouteEditorState::new(
9921 worker.instance_id,
9922 worker.label,
9923 lodging,
9924 )
9925 };
9926 self.state.worker_route_editor = Some(editor);
9927 if let Some(ed) = self.state.worker_route_editor.as_mut() {
9928 if let Some(collapsed) =
9929 crate::client_config::ClientConfig::load().worker_route_panel_collapsed
9930 {
9931 ed.panel_collapsed = collapsed;
9932 }
9933 }
9934 self.state.show_workers_menu = false;
9935 self.state.push_log(format!(
9936 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
9937 ));
9938 Ok(())
9939 }
9940
9941 pub fn close_worker_route_editor(&mut self) {
9942 self.state.worker_route_editor = None;
9943 }
9944
9945 pub fn worker_route_editor_toggle_panel(&mut self) {
9946 if let Some(ed) = self.state.worker_route_editor.as_mut() {
9947 ed.toggle_panel_collapsed();
9948 let collapsed = ed.panel_collapsed;
9949 let mut cfg = crate::client_config::ClientConfig::load();
9950 let _ = cfg.save_worker_route_panel_collapsed(collapsed);
9951 }
9952 }
9953
9954 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
9955 let n = {
9956 let Some(ed) = self.state.worker_route_editor.as_mut() else {
9957 return;
9958 };
9959 ed.append_waypoint(x, y, z);
9960 ed.stop_count()
9961 };
9962 self.state
9963 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
9964 }
9965
9966 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
9969 let (px, py, _) = self.state.player_position_with_z();
9970 crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
9971 &self.state.placed_containers,
9972 &self.state.buildings,
9973 self.state.character_id,
9974 px,
9975 py,
9976 &self.state.hired_workers,
9977 )
9978 }
9979
9980 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
9981 self.state.route_editor_node_candidates()
9982 }
9983
9984 fn re_open_harvest_picker(
9985 &mut self,
9986 index: usize,
9987 picked: std::collections::BTreeSet<String>,
9988 ) {
9989 use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
9990 let nodes = self.state.route_editor_node_candidates();
9991 let index = if nodes.is_empty() {
9992 ROUTE_PICKER_DONE_ROW
9993 } else {
9994 index.max(1).min(nodes.len())
9995 };
9996 self.re_open_sheet(S::HarvestPicker {
9997 index,
9998 picked,
9999 nodes,
10000 });
10001 }
10002
10003 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
10004 let (px, py, _) = self.state.player_position_with_z();
10005 crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
10006 }
10007
10008 fn re_template_candidates(&self) -> Vec<String> {
10009 let mut extra = Vec::new();
10010 if let Some(ed) = self.state.worker_route_editor.as_ref() {
10011 for stop in &ed.stops {
10012 match stop {
10013 crate::worker_route_editor::WorkerRouteStop::DepositAt {
10014 filter: Some(filter),
10015 ..
10016 } => extra.extend(filter.iter().cloned()),
10017 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
10018 extra.push(template.clone());
10019 }
10020 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
10021 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
10022 extra.push(bp.output.clone());
10023 for input in &bp.inputs {
10024 extra.push(input.template_id.clone());
10025 }
10026 }
10027 }
10028 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
10029 for it in items {
10030 extra.push(it.template.clone());
10031 }
10032 }
10033 _ => {}
10034 }
10035 }
10036 if let Some(worker) = self
10038 .state
10039 .hired_workers
10040 .iter()
10041 .find(|w| w.instance_id == ed.worker_instance_id)
10042 {
10043 for recipe in &worker.known_blueprint_ids {
10044 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
10045 extra.push(bp.output.clone());
10046 }
10047 }
10048 }
10049 }
10050 crate::worker_route_editor::route_item_template_candidates(
10051 &self.state.placed_containers,
10052 self.state.character_id,
10053 &self.state.inventory,
10054 &self.state.blueprints,
10055 &self.state.resource_nodes,
10056 &extra,
10057 )
10058 }
10059
10060 fn re_blueprint_ids(&self) -> Vec<String> {
10061 let worker_known: Option<&[String]> = self
10062 .state
10063 .worker_route_editor
10064 .as_ref()
10065 .and_then(|ed| {
10066 self.state
10067 .hired_workers
10068 .iter()
10069 .find(|w| w.instance_id == ed.worker_instance_id)
10070 })
10071 .map(|w| w.known_blueprint_ids.as_slice());
10072 crate::worker_route_editor::worker_craft_blueprint_ids(
10073 &self.state.blueprints,
10074 worker_known,
10075 )
10076 }
10077
10078 fn re_bed_candidates(&self) -> Vec<(String, String)> {
10079 crate::worker_route_editor::owned_lodging_container_ids(
10080 &self.state.placed_containers,
10081 self.state.character_id,
10082 )
10083 }
10084
10085 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
10086 self.state
10087 .placed_containers
10088 .iter()
10089 .find(|c| c.id == container_id)
10090 .map(|c| c.contents.clone())
10091 .unwrap_or_default()
10092 }
10093
10094 fn re_sheet_supports_filter(&self) -> bool {
10097 use crate::worker_route_editor::RouteEditorSheet as S;
10098 self.state
10099 .worker_route_editor
10100 .as_ref()
10101 .is_some_and(|ed| {
10102 matches!(
10103 ed.sheet,
10104 S::HarvestPicker { .. }
10105 | S::SellItem { .. }
10106 | S::DepositFilter { .. }
10107 | S::WithdrawItems { .. }
10108 | S::WithdrawContainers { .. }
10109 | S::DepositContainers { .. }
10110 | S::SellNpcs { .. }
10111 | S::CraftBlueprint { .. }
10112 | S::BedPicker { .. }
10113 )
10114 })
10115 }
10116
10117 pub fn re_sheet_row_visible(&self, row: usize) -> bool {
10119 use crate::worker_route_editor::{
10120 harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
10121 ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
10122 };
10123 let Some(ed) = self.state.worker_route_editor.as_ref() else {
10124 return false;
10125 };
10126 let filter = &ed.sheet_filter;
10127 match &ed.sheet {
10128 S::HarvestPicker { nodes, .. } => {
10129 harvest_picker_row_matches(nodes, row, filter)
10130 }
10131 S::SellItem { templates, .. } => {
10132 if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
10133 return true;
10134 }
10135 let slot = row.saturating_sub(2);
10136 templates.get(slot).is_some_and(|t| {
10137 let label = self.state.template_display_name(t);
10138 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10139 })
10140 }
10141 S::DepositFilter { rows, .. } => {
10142 if row >= rows.len() {
10143 return true;
10144 }
10145 rows.get(row).is_some_and(|(t, _)| {
10146 let label = self.state.template_display_name(t);
10147 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10148 })
10149 }
10150 S::WithdrawItems { lines, .. } => {
10151 if row >= lines.len() {
10152 return true;
10153 }
10154 lines.get(row).is_some_and(|l| {
10155 let label = self.state.template_display_name(&l.template);
10156 list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
10157 })
10158 }
10159 S::WithdrawContainers { .. } | S::DepositContainers { .. } => self
10160 .re_container_candidates()
10161 .get(row)
10162 .is_some_and(|c| {
10163 list_filter_row_matches(
10164 filter,
10165 Some(c.dist),
10166 &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
10167 )
10168 }),
10169 S::SellNpcs { .. } => {
10170 if row == 0 {
10171 return true;
10172 }
10173 self.re_npc_candidates().get(row - 1).is_some_and(|n| {
10174 list_filter_row_matches(filter, Some(n.dist), &[n.label.as_str(), n.id.as_str()])
10175 })
10176 }
10177 S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
10178 let label = self
10179 .state
10180 .blueprints
10181 .iter()
10182 .find(|b| &b.id == id)
10183 .map(|b| {
10184 if b.label.is_empty() {
10185 id.as_str()
10186 } else {
10187 b.label.as_str()
10188 }
10189 })
10190 .unwrap_or(id.as_str());
10191 list_filter_row_matches(filter, None, &[id.as_str(), label])
10192 }),
10193 S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
10194 list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
10195 }),
10196 _ => true,
10197 }
10198 }
10199
10200 fn re_sheet_clamp_index(&mut self) {
10201 let count = self.re_sheet_row_count();
10202 if count == 0 {
10203 return;
10204 }
10205 let cur = self.re_sheet_index();
10206 if self.re_sheet_row_visible(cur) {
10207 return;
10208 }
10209 for offset in 1..count {
10210 if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
10211 self.re_sheet_set_index(cur + offset);
10212 return;
10213 }
10214 if cur >= offset && self.re_sheet_row_visible(cur - offset) {
10215 self.re_sheet_set_index(cur - offset);
10216 return;
10217 }
10218 }
10219 }
10220
10221 fn re_sheet_set_index(&mut self, index: usize) {
10222 use crate::worker_route_editor::RouteEditorSheet as S;
10223 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10224 return;
10225 };
10226 match &mut ed.sheet {
10227 S::AddMenu { index: slot }
10228 | S::WaypointMenu { index: slot }
10229 | S::HarvestPicker { index: slot, .. }
10230 | S::WithdrawContainers { index: slot }
10231 | S::DepositContainers { index: slot }
10232 | S::SellNpcs { index: slot }
10233 | S::CraftBlueprint { index: slot }
10234 | S::BedPicker { index: slot }
10235 | S::FarmPlotPicker { index: slot, .. }
10236 | S::FarmPlantSeed { index: slot, .. }
10237 | S::WithdrawItems { index: slot, .. }
10238 | S::DepositFilter { index: slot, .. }
10239 | S::SellItem { index: slot, .. } => *slot = index,
10240 _ => {}
10241 }
10242 }
10243
10244 pub fn re_focus_sheet_filter(&mut self) {
10245 if !self.re_sheet_supports_filter() {
10246 return;
10247 }
10248 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10249 ed.sheet_filter_focused = true;
10250 }
10251 }
10252
10253 pub fn re_blur_sheet_filter_keep_text(&mut self) {
10254 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10255 return;
10256 };
10257 if !ed.sheet_filter_focused {
10258 return;
10259 }
10260 ed.sheet_filter_focused = false;
10261 self.re_sheet_clamp_index();
10262 }
10263
10264 pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
10265 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10266 return false;
10267 };
10268 if ed.sheet_filter_focused {
10269 ed.sheet_filter_focused = false;
10270 self.re_sheet_clamp_index();
10271 return true;
10272 }
10273 if !ed.sheet_filter.is_empty() {
10274 ed.sheet_filter.clear();
10275 self.re_sheet_clamp_index();
10276 return true;
10277 }
10278 false
10279 }
10280
10281 pub fn re_append_sheet_filter_char(&mut self, ch: char) {
10282 if ch.is_control() {
10283 return;
10284 }
10285 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10286 return;
10287 };
10288 if !ed.sheet_filter_focused {
10289 return;
10290 }
10291 ed.sheet_filter.push(ch);
10292 self.re_sheet_set_index(0);
10293 self.re_sheet_clamp_index();
10294 }
10295
10296 pub fn re_sheet_filter_backspace(&mut self) {
10297 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10298 return;
10299 };
10300 if !ed.sheet_filter_focused {
10301 return;
10302 }
10303 ed.sheet_filter.pop();
10304 self.re_sheet_set_index(0);
10305 self.re_sheet_clamp_index();
10306 }
10307
10308 pub fn re_sheet_row_count(&self) -> usize {
10310 use crate::worker_route_editor::{
10311 harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
10312 };
10313 let Some(ed) = self.state.worker_route_editor.as_ref() else {
10314 return 0;
10315 };
10316 match &ed.sheet {
10317 S::Stops => ed.stops.len(),
10318 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
10319 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
10320 S::WaypointMapPick => 0,
10321 S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
10322 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
10323 self.re_container_candidates().len()
10324 }
10325 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()),
10329 S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
10330 S::WaitEntry { .. } => 1,
10331 S::BedPicker { .. } => self.re_bed_candidates().len(),
10332 S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
10333 S::FarmPlantSeed { seeds, .. } => seeds.len(),
10334 }
10335 }
10336
10337 pub fn re_sheet_index(&self) -> usize {
10339 use crate::worker_route_editor::RouteEditorSheet as S;
10340 let Some(ed) = self.state.worker_route_editor.as_ref() else {
10341 return 0;
10342 };
10343 match &ed.sheet {
10344 S::AddMenu { index }
10345 | S::WaypointMenu { index }
10346 | S::HarvestPicker { index, .. }
10347 | S::WithdrawContainers { index }
10348 | S::DepositContainers { index }
10349 | S::SellNpcs { index }
10350 | S::CraftBlueprint { index }
10351 | S::BedPicker { index }
10352 | S::FarmPlotPicker { index, .. }
10353 | S::FarmPlantSeed { index, .. }
10354 | S::WithdrawItems { index, .. }
10355 | S::DepositFilter { index, .. }
10356 | S::SellItem { index, .. } => *index,
10357 _ => 0,
10358 }
10359 }
10360
10361 pub fn re_sheet_move(&mut self, delta: i32) {
10363 let count = self.re_sheet_row_count();
10364 if count == 0 {
10365 return;
10366 }
10367 let cur = self.re_sheet_index();
10368 let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
10369 self.re_sheet_set_index(next);
10370 }
10371
10372 pub fn re_sheet_page(&mut self, pages: i32) {
10373 let count = self.re_sheet_row_count();
10374 if count == 0 {
10375 return;
10376 }
10377 let cur = self.re_sheet_index();
10378 let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
10379 self.re_sheet_set_index(next);
10380 }
10381
10382 pub fn re_sheet_adjust(&mut self, delta: i32) {
10384 use crate::worker_route_editor::RouteEditorSheet as S;
10385 let index = self.re_sheet_index();
10386 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10387 return;
10388 };
10389 match &mut ed.sheet {
10390 S::WithdrawItems { lines, .. } => {
10391 if let Some(line) = lines.get_mut(index) {
10392 line.adjust_qty(delta);
10393 }
10394 }
10395 S::WaitEntry { ticks } => {
10396 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
10397 }
10398 _ => {}
10399 }
10400 }
10401
10402 pub fn re_sheet_back(&mut self) {
10403 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10404 return;
10405 };
10406 use crate::worker_route_editor::RouteEditorSheet as S;
10407 let was_editing = ed.editing_index.is_some();
10408 let from_top_picker = matches!(
10409 ed.sheet,
10410 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
10411 );
10412 ed.sheet_back();
10413 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
10414 self.state
10416 .push_log("Route: left edit sheet — press s to save current stops".to_string());
10417 }
10418 }
10419
10420 pub fn re_at_root_sheet(&self) -> bool {
10422 self.state
10423 .worker_route_editor
10424 .as_ref()
10425 .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
10426 }
10427
10428 pub fn re_open_add_menu(&mut self) {
10429 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10430 ed.open_add_menu();
10431 }
10432 }
10433
10434 pub fn re_open_bed_picker(&mut self) {
10435 let beds = self.re_bed_candidates();
10436 if beds.is_empty() {
10437 self.state
10438 .push_log("Route: place a camp bed first".to_string());
10439 return;
10440 }
10441 let current = self
10442 .state
10443 .worker_route_editor
10444 .as_ref()
10445 .and_then(|ed| ed.lodging_container_id.clone());
10446 let index = current
10447 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
10448 .unwrap_or(0);
10449 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
10450 }
10451
10452 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
10453 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10454 ed.open_sheet(sheet);
10455 }
10456 }
10457
10458 fn re_confirm_stop(
10460 &mut self,
10461 stop: crate::worker_route_editor::WorkerRouteStop,
10462 what: String,
10463 ) {
10464 let appended = self
10465 .state
10466 .worker_route_editor
10467 .as_mut()
10468 .is_some_and(|ed| ed.confirm_stop(stop));
10469 if appended {
10470 self.state.push_log(format!("Route: + {what}"));
10471 } else {
10472 self.state
10473 .push_log(format!("Route: {what} already in route — selected it"));
10474 }
10475 }
10476
10477 fn re_open_withdraw_items(&mut self, container_id: String) {
10478 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
10479 let contents = self.re_container_contents(&container_id);
10480 let existing = self
10484 .state
10485 .worker_route_editor
10486 .as_ref()
10487 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10488 .and_then(|stop| match stop {
10489 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
10490 _ => None,
10491 })
10492 .unwrap_or_default();
10493 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
10494 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10497 let _ = ed.retarget_withdraw_container(container_id.clone());
10498 }
10499 self.re_open_sheet(S::WithdrawItems {
10500 container_id,
10501 lines,
10502 index: 0,
10503 });
10504 }
10505
10506 fn re_withdraw_items_activate(&mut self, index: usize) {
10507 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
10508 enum Outcome {
10509 Cycled,
10510 Confirmed(String),
10511 Empty,
10512 }
10513 let outcome = {
10514 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10515 return;
10516 };
10517 let S::WithdrawItems {
10518 container_id,
10519 lines,
10520 index: sheet_index,
10521 } = &mut ed.sheet
10522 else {
10523 return;
10524 };
10525 *sheet_index = index;
10526 if index < lines.len() {
10527 lines[index].cycle();
10528 Outcome::Cycled
10529 } else {
10530 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
10531 if items.is_empty() {
10532 Outcome::Empty
10533 } else {
10534 let stop = WorkerRouteStop::WithdrawFrom {
10535 container_id: container_id.clone(),
10536 items,
10537 };
10538 let summary = stop.summary();
10539 ed.confirm_stop(stop);
10540 Outcome::Confirmed(summary)
10541 }
10542 }
10543 };
10544 match outcome {
10545 Outcome::Cycled => {}
10546 Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
10547 Outcome::Empty => self
10548 .state
10549 .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
10550 }
10551 }
10552
10553 fn re_open_deposit_filter(&mut self, container_id: String) {
10554 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10555 let existing_filter = self
10557 .state
10558 .worker_route_editor
10559 .as_ref()
10560 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10561 .and_then(|stop| match stop {
10562 WorkerRouteStop::DepositAt { filter, .. } => {
10563 Some(filter.clone().unwrap_or_default())
10564 }
10565 _ => None,
10566 });
10567 let mut candidates = self.re_template_candidates();
10568 if let Some(ref chosen) = existing_filter {
10569 for t in chosen {
10570 if !candidates.iter().any(|c| c == t) {
10571 candidates.push(t.clone());
10572 }
10573 }
10574 candidates.sort();
10575 candidates.dedup();
10576 }
10577 let rows: Vec<(String, bool)> = match existing_filter {
10578 Some(chosen) => candidates
10579 .iter()
10580 .map(|t| (t.clone(), chosen.contains(t)))
10581 .collect(),
10582 None => candidates.into_iter().map(|t| (t, false)).collect(),
10583 };
10584 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10585 let _ = ed.retarget_deposit_container(container_id.clone());
10586 }
10587 self.re_open_sheet(S::DepositFilter {
10588 container_id,
10589 rows,
10590 index: 0,
10591 });
10592 }
10593
10594 fn re_deposit_filter_activate(&mut self, index: usize) {
10595 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10596 let mut confirmed: Option<String> = None;
10597 {
10598 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10599 return;
10600 };
10601 let S::DepositFilter {
10602 container_id,
10603 rows,
10604 index: sheet_index,
10605 } = &mut ed.sheet
10606 else {
10607 return;
10608 };
10609 *sheet_index = index;
10610 if index < rows.len() {
10611 rows[index].1 = !rows[index].1;
10612 } else {
10613 let chosen: Vec<String> = rows
10615 .iter()
10616 .filter(|(_, on)| *on)
10617 .map(|(t, _)| t.clone())
10618 .collect();
10619 let filter = if chosen.is_empty() { None } else { Some(chosen) };
10620 let stop = WorkerRouteStop::DepositAt {
10621 container_id: container_id.clone(),
10622 filter,
10623 };
10624 confirmed = Some(stop.summary());
10625 ed.confirm_stop(stop);
10626 }
10627 }
10628 if let Some(what) = confirmed {
10629 self.state.push_log(format!("Route: + {what}"));
10630 }
10631 }
10632
10633 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
10634 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10635 let templates = self.re_template_candidates();
10636 if templates.is_empty() {
10637 self.state.push_log(
10638 "Route: no item templates available — learn a craft recipe or place a harvest node first"
10639 .to_string(),
10640 );
10641 return;
10642 }
10643 let (pre_npc, pre_template, pre_all) = self
10645 .state
10646 .worker_route_editor
10647 .as_ref()
10648 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10649 .and_then(|stop| match stop {
10650 WorkerRouteStop::TradeWith {
10651 npc_id,
10652 template,
10653 sell_all,
10654 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
10655 _ => None,
10656 })
10657 .unwrap_or((None, None, true));
10658 let npc_id = npc_id.or(pre_npc);
10659 let mut picked = std::collections::BTreeSet::new();
10660 if let Some(t) = pre_template {
10661 picked.insert(t);
10662 }
10663 self.re_open_sheet(S::SellItem {
10664 npc_id,
10665 templates,
10666 index: if picked.is_empty() {
10667 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
10668 } else {
10669 2
10670 },
10671 sell_all: pre_all,
10672 picked,
10673 });
10674 }
10675
10676 fn re_sell_item_activate(&mut self, index: usize) {
10677 use crate::worker_route_editor::{
10678 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
10679 };
10680 let mut batch_log: Option<String> = None;
10681 {
10682 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10683 return;
10684 };
10685 let S::SellItem {
10686 npc_id,
10687 templates,
10688 index: sheet_index,
10689 sell_all,
10690 picked,
10691 } = &mut ed.sheet
10692 else {
10693 return;
10694 };
10695 *sheet_index = index;
10696 if index == ROUTE_PICKER_DONE_ROW {
10697 if picked.is_empty() {
10698 batch_log = Some(
10699 "Route: pick at least one item (Space toggles, Done confirms)".into(),
10700 );
10701 } else {
10702 let picks: Vec<String> = picked.iter().cloned().collect();
10703 let npc = npc_id.clone();
10704 let all = *sell_all;
10705 let added = ed.confirm_trade_picks(npc, &picks, all);
10706 batch_log = Some(format!("Route: + {added} sell stop(s)"));
10707 }
10708 } else if index == SELL_ITEM_TOGGLE_ROW {
10709 *sell_all = !*sell_all;
10710 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
10711 if picked.contains(template) {
10712 picked.remove(template);
10713 } else {
10714 picked.insert(template.clone());
10715 }
10716 }
10717 }
10718 if let Some(msg) = batch_log {
10719 self.state.push_log(msg);
10720 }
10721 }
10722
10723 pub fn re_edit_selected_stop(&mut self) {
10725 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10726 let Some(stop) = self
10727 .state
10728 .worker_route_editor
10729 .as_ref()
10730 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
10731 else {
10732 self.state
10733 .push_log("Route: no stop selected — press a to add one".to_string());
10734 return;
10735 };
10736 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10737 ed.begin_edit_selected();
10738 }
10739 match stop {
10740 WorkerRouteStop::Waypoint { .. } => {
10741 self.re_open_sheet(S::WaypointMenu { index: 0 });
10742 }
10743 WorkerRouteStop::HarvestNode { node_id } => {
10744 let nodes = self.state.route_editor_node_candidates();
10745 if nodes.is_empty() {
10746 self.re_cancel_edit();
10747 self.state
10748 .push_log("Route: no harvestable nodes visible to retarget".to_string());
10749 } else {
10750 let mut picked = std::collections::BTreeSet::new();
10751 picked.insert(node_id.clone());
10752 let index = nodes
10753 .iter()
10754 .position(|n| n.id == node_id)
10755 .map(|i| i + 1)
10756 .unwrap_or(1);
10757 self.re_open_harvest_picker(index, picked);
10758 }
10759 }
10760 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
10761 let containers = self.re_container_candidates();
10764 if containers.is_empty() {
10765 self.re_cancel_edit();
10766 self.state
10767 .push_log("Route: place a storage chest first".to_string());
10768 } else {
10769 let index = containers
10770 .iter()
10771 .position(|c| c.id == container_id)
10772 .unwrap_or(0);
10773 self.re_open_sheet(S::WithdrawContainers { index });
10774 }
10775 }
10776 WorkerRouteStop::DepositAt { container_id, .. } => {
10777 let containers = self.re_container_candidates();
10778 if containers.is_empty() {
10779 self.re_cancel_edit();
10780 self.state
10781 .push_log("Route: place a storage chest first".to_string());
10782 } else {
10783 let index = containers
10784 .iter()
10785 .position(|c| c.id == container_id)
10786 .unwrap_or(0);
10787 self.re_open_sheet(S::DepositContainers { index });
10788 }
10789 }
10790 WorkerRouteStop::TradeWith { npc_id, .. } => {
10791 let npcs = self.re_npc_candidates();
10792 let index = npc_id
10794 .as_ref()
10795 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
10796 .unwrap_or(0);
10797 self.re_open_sheet(S::SellNpcs { index });
10798 }
10799 WorkerRouteStop::CraftAt { blueprint, .. } => {
10800 let bps = self.re_blueprint_ids();
10801 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
10802 if bps.is_empty() {
10803 self.re_cancel_edit();
10804 self.state
10805 .push_log("Route: no known blueprints to retarget".to_string());
10806 } else {
10807 self.re_open_sheet(S::CraftBlueprint { index });
10808 }
10809 }
10810 WorkerRouteStop::CultivatePlot { .. } => {
10811 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate);
10812 }
10813 WorkerRouteStop::PlantPlot { .. } => {
10814 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
10815 }
10816 WorkerRouteStop::HarvestPlot { .. } => {
10817 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
10818 }
10819 WorkerRouteStop::RestIfNeeded => {
10820 self.re_cancel_edit();
10821 self.state
10822 .push_log("Route: rest has no settings (change the bed with l)".to_string());
10823 }
10824 WorkerRouteStop::Wait { wait_ticks } => {
10825 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
10826 }
10827 }
10828 }
10829
10830 fn re_cancel_edit(&mut self) {
10831 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10832 ed.editing_index = None;
10833 }
10834 }
10835
10836 pub fn worker_route_editor_ui_click(
10839 &mut self,
10840 click: crate::worker_route_editor::RouteEditorClick,
10841 ) {
10842 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
10843 match click {
10844 RouteEditorClick::SelectStop(i) => {
10845 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10846 ed.sheet = S::Stops;
10847 ed.select_stop(i);
10848 }
10849 }
10850 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
10851 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
10852 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
10853 }
10854 }
10855
10856 pub fn re_sheet_row_activate(&mut self, row: usize) {
10858 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10859 let Some(sheet) = self
10860 .state
10861 .worker_route_editor
10862 .as_ref()
10863 .map(|ed| ed.sheet.clone())
10864 else {
10865 return;
10866 };
10867 match sheet {
10868 S::Stops => {
10869 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10870 ed.select_stop(row);
10871 }
10872 }
10873 S::AddMenu { .. } => match row {
10874 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
10875 1 => {
10876 if self.re_node_candidates().is_empty() {
10877 self.state
10878 .push_log("Route: no harvestable nodes visible in this region".to_string());
10879 } else {
10880 self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
10881 }
10882 }
10883 2 | 3 => {
10884 if self.re_container_candidates().is_empty() {
10885 self.state
10886 .push_log("Route: place a storage chest first".to_string());
10887 } else if row == 2 {
10888 self.re_open_sheet(S::WithdrawContainers { index: 0 });
10889 } else {
10890 self.re_open_sheet(S::DepositContainers { index: 0 });
10891 }
10892 }
10893 4 => {
10894 if self.re_template_candidates().is_empty() {
10895 self.state.push_log(
10896 "Route: no item templates available — learn a craft recipe or place a harvest node first"
10897 .to_string(),
10898 );
10899 } else {
10900 self.re_open_sheet(S::SellNpcs { index: 0 });
10901 }
10902 }
10903 5 => {
10904 if self.re_blueprint_ids().is_empty() {
10905 self.state.push_log(
10906 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
10907 .to_string(),
10908 );
10909 } else {
10910 self.re_open_sheet(S::CraftBlueprint { index: 0 });
10911 }
10912 }
10913 6 => self.re_confirm_stop(
10914 WorkerRouteStop::RestIfNeeded,
10915 "rest at lodging (if needed)".into(),
10916 ),
10917 7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
10918 8 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate),
10919 9 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant),
10920 10 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
10921 _ => {}
10922 },
10923 S::WaypointMenu { .. } => match row {
10924 0 => {
10925 let (x, y, z) = self.state.player_position_with_z();
10926 let stop = WorkerRouteStop::Waypoint { x, y, z };
10927 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
10928 }
10929 1 => {
10930 self.re_open_sheet(S::WaypointMapPick);
10931 self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
10932 }
10933 _ => {}
10934 },
10935 S::HarvestPicker { .. } => {
10936 let mut log: Option<String> = None;
10937 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10938 let S::HarvestPicker {
10939 index: sheet_index,
10940 picked,
10941 nodes,
10942 } = &mut ed.sheet
10943 else {
10944 return;
10945 };
10946 *sheet_index = row;
10947 if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
10948 if picked.is_empty() {
10949 log = Some(
10950 "Route: pick at least one node (Space toggles, Done confirms)"
10951 .into(),
10952 );
10953 } else {
10954 let ids: Vec<String> = picked.iter().cloned().collect();
10955 let added = ed.confirm_harvest_picks(&ids);
10956 log = Some(format!("Route: + {added} harvest stop(s)"));
10957 }
10958 } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
10959 if picked.contains(&n.id) {
10960 picked.remove(&n.id);
10961 } else {
10962 picked.insert(n.id.clone());
10963 }
10964 }
10965 }
10966 if let Some(msg) = log {
10967 self.state.push_log(msg);
10968 }
10969 }
10970 S::WithdrawContainers { .. } => {
10971 let containers = self.re_container_candidates();
10972 if let Some(c) = containers.get(row) {
10973 let id = c.id.clone();
10974 self.re_open_withdraw_items(id);
10975 }
10976 }
10977 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
10978 S::DepositContainers { .. } => {
10979 let containers = self.re_container_candidates();
10980 if let Some(c) = containers.get(row) {
10981 let id = c.id.clone();
10982 self.re_open_deposit_filter(id);
10983 }
10984 }
10985 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
10986 S::SellNpcs { .. } => {
10987 let npcs = self.re_npc_candidates();
10988 let npc_id = if row == 0 {
10989 None
10990 } else {
10991 npcs.get(row - 1).map(|n| n.id.clone())
10992 };
10993 if row == 0 || npc_id.is_some() {
10994 self.re_open_sell_item(npc_id);
10995 }
10996 }
10997 S::SellItem { .. } => self.re_sell_item_activate(row),
10998 S::CraftBlueprint { .. } => {
10999 let bps = self.re_blueprint_ids();
11000 if let Some(bp) = bps.get(row) {
11001 let stop = WorkerRouteStop::CraftAt {
11002 device: "hand".into(),
11003 blueprint: bp.clone(),
11004 qty: None,
11005 };
11006 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
11007 }
11008 }
11009 S::WaitEntry { ticks } => {
11010 let stop = WorkerRouteStop::Wait {
11011 wait_ticks: ticks,
11012 };
11013 self.re_confirm_stop(stop, format!("wait {ticks}t"));
11014 }
11015 S::BedPicker { .. } => {
11016 let beds = self.re_bed_candidates();
11017 if let Some((id, name)) = beds.get(row) {
11018 let (id, name) = (id.clone(), name.clone());
11019 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11020 ed.lodging_container_id = Some(id.clone());
11021 ed.sheet = S::Stops;
11022 }
11023 self.state
11024 .push_log(format!("Route: rest bed set to {name}"));
11025 }
11026 }
11027 S::FarmPlotPicker { action, .. } => {
11028 let plots = self.re_farm_plot_candidates();
11029 let Some(plot) = plots.get(row).cloned() else {
11030 return;
11031 };
11032 match action {
11033 crate::worker_route_editor::FarmPlotAction::Cultivate => {
11034 let label = plot_route_label(&plot);
11035 self.re_confirm_stop(
11036 WorkerRouteStop::CultivatePlot {
11037 plot_id: plot.plot_id,
11038 },
11039 format!("cultivate {label}"),
11040 );
11041 }
11042 crate::worker_route_editor::FarmPlotAction::Harvest => {
11043 let label = plot_route_label(&plot);
11044 self.re_confirm_stop(
11045 WorkerRouteStop::HarvestPlot {
11046 plot_id: plot.plot_id,
11047 },
11048 format!("harvest {label}"),
11049 );
11050 }
11051 crate::worker_route_editor::FarmPlotAction::Plant => {
11052 let seeds = self.re_farm_seed_candidates();
11053 if seeds.is_empty() {
11054 self.state.push_log(
11055 "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
11056 );
11057 return;
11058 }
11059 self.re_open_sheet(S::FarmPlantSeed {
11060 plot_id: plot.plot_id,
11061 seeds,
11062 index: 0,
11063 });
11064 }
11065 }
11066 }
11067 S::FarmPlantSeed { plot_id, seeds, .. } => {
11068 if let Some(seed) = seeds.get(row).cloned() {
11069 self.re_confirm_stop(
11070 WorkerRouteStop::PlantPlot {
11071 plot_id,
11072 seed_template: seed.clone(),
11073 },
11074 format!("plant {seed}"),
11075 );
11076 }
11077 }
11078 S::WaypointMapPick => {}
11079 }
11080 }
11081
11082 fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
11083 use crate::worker_route_editor::RouteEditorSheet as S;
11084 if self.re_farm_plot_candidates().is_empty() {
11085 self.state.push_log(
11086 "Route: no farmable plots visible — claim land or get farm access first",
11087 );
11088 return;
11089 }
11090 self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
11091 }
11092
11093 fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
11094 self.state
11095 .property_plots
11096 .iter()
11097 .filter(|p| p.is_mine || p.may_farm)
11098 .cloned()
11099 .collect()
11100 }
11101
11102 fn re_farm_seed_candidates(&self) -> Vec<String> {
11106 let mut set = std::collections::BTreeSet::new();
11107 let looks_like_seed = |id: &str| {
11108 id.ends_with("_seed") || id == "potato_seed" || id == "carrot_seed"
11109 };
11110 for (id, _, _) in self.state.farm_seed_entries() {
11111 set.insert(id);
11112 }
11113 for c in &self.state.placed_containers {
11114 let mine = match (self.state.character_id, c.owner_character_id) {
11115 (Some(a), Some(b)) => a == b,
11116 _ => false,
11117 };
11118 if !mine {
11119 continue;
11120 }
11121 for s in &c.contents {
11122 if s.quantity > 0
11123 && (s.props.contains_key("seed_for") || looks_like_seed(&s.template_id))
11124 {
11125 set.insert(s.template_id.clone());
11126 }
11127 }
11128 }
11129 if let Some(ed) = self.state.worker_route_editor.as_ref() {
11130 for stop in &ed.stops {
11131 if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } = stop
11132 {
11133 for it in items {
11134 if looks_like_seed(&it.template) {
11135 set.insert(it.template.clone());
11136 }
11137 }
11138 }
11139 if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
11140 seed_template, ..
11141 } = stop
11142 {
11143 if !seed_template.is_empty() {
11144 set.insert(seed_template.clone());
11145 }
11146 }
11147 }
11148 }
11149 for id in self.state.inventory_hints.keys() {
11150 if looks_like_seed(id) {
11151 set.insert(id.clone());
11152 }
11153 }
11154 for id in ["potato_seed", "carrot_seed"] {
11156 set.insert(id.to_string());
11157 }
11158 set.into_iter().collect()
11159 }
11160
11161 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
11168 use crate::worker_route_editor as wre;
11169 use wre::RouteEditorSheet as S;
11170 if self.state.worker_route_editor.is_none() {
11171 return;
11172 }
11173 let sheet = self
11174 .state
11175 .worker_route_editor
11176 .as_ref()
11177 .map(|ed| ed.sheet.clone())
11178 .unwrap_or(S::Stops);
11179 match sheet {
11180 S::WaypointMapPick => {
11181 let (_, _, z) = self.state.player_position_with_z();
11182 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
11183 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
11184 let editing = self
11186 .state
11187 .worker_route_editor
11188 .as_ref()
11189 .is_some_and(|ed| ed.editing_index.is_some());
11190 if !editing {
11191 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11192 ed.sheet = S::WaypointMapPick;
11193 }
11194 }
11195 }
11196 S::HarvestPicker { .. } => {
11197 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11198 let mut log: Option<String> = None;
11199 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11200 let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
11201 return;
11202 };
11203 let selected = if picked.contains(&node.id) {
11204 picked.remove(&node.id);
11205 false
11206 } else {
11207 picked.insert(node.id.clone());
11208 true
11209 };
11210 log = Some(format!(
11211 "Route: {} {}",
11212 if selected { "selected" } else { "deselected" },
11213 node.label
11214 ));
11215 }
11216 if let Some(msg) = log {
11217 self.state.push_log(msg);
11218 }
11219 }
11220 }
11221 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
11222 if let Some(cid) = wre::pick_storage_container_at(
11224 &self.state.placed_containers,
11225 self.state.character_id,
11226 x,
11227 y,
11228 ) {
11229 self.re_open_withdraw_items(cid);
11230 }
11231 }
11232 S::DepositContainers { .. } | S::DepositFilter { .. } => {
11233 if let Some(cid) = wre::pick_storage_container_at(
11234 &self.state.placed_containers,
11235 self.state.character_id,
11236 x,
11237 y,
11238 ) {
11239 self.re_open_deposit_filter(cid);
11240 }
11241 }
11242 S::SellNpcs { .. } => {
11243 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11244 self.re_open_sell_item(Some(npc_id));
11245 }
11246 }
11247 S::SellItem { .. } => {
11248 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11249 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11250 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
11251 *slot = Some(npc_id.clone());
11252 }
11253 }
11254 self.state
11255 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11256 }
11257 }
11258 _ => self.worker_route_editor_quick_add_click(x, y),
11260 }
11261 }
11262
11263 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
11267 use crate::worker_route_editor as wre;
11268 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
11269 let dx = ax - bx;
11270 let dy = ay - by;
11271 (dx * dx + dy * dy).sqrt()
11272 };
11273
11274 let selected_stop_kind = self
11277 .state
11278 .worker_route_editor
11279 .as_ref()
11280 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
11281 .map(|s| match s {
11282 wre::WorkerRouteStop::TradeWith { .. } => 1,
11283 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
11284 _ => 0,
11285 })
11286 .unwrap_or(0);
11287 if selected_stop_kind == 1 {
11288 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11289 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11290 ed.set_selected_trade_npc(npc_id.clone());
11291 }
11292 self.state
11293 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11294 return;
11295 }
11296 }
11297 if selected_stop_kind == 2 {
11298 if let Some(cid) = wre::pick_storage_container_at(
11299 &self.state.placed_containers,
11300 self.state.character_id,
11301 x,
11302 y,
11303 ) {
11304 let name = self
11305 .state
11306 .placed_containers
11307 .iter()
11308 .find(|c| c.id == cid)
11309 .map(|c| c.display_name.clone())
11310 .unwrap_or_else(|| "container".into());
11311 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11312 ed.set_selected_withdraw_container(cid.clone());
11313 }
11314 self.state
11315 .push_log(format!("Route: withdraw source → {name}"));
11316 return;
11317 }
11318 }
11319
11320 enum Target {
11323 Bed(String),
11324 Container(String),
11325 Npc(String, String),
11326 Node(String, String),
11327 }
11328 let mut best: Option<(f32, u8, Target)> = None;
11329 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
11330 let better = match best {
11331 None => true,
11332 Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
11333 };
11334 if better {
11335 *best = Some((d, rank, t));
11336 }
11337 };
11338 if let Some(bed_id) = wre::pick_lodging_container_at(
11339 &self.state.placed_containers,
11340 self.state.character_id,
11341 x,
11342 y,
11343 ) {
11344 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
11345 let already_bed = self
11348 .state
11349 .worker_route_editor
11350 .as_ref()
11351 .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
11352 if already_bed {
11353 consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
11354 } else {
11355 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
11356 }
11357 }
11358 }
11359 if let Some(cid) = wre::pick_storage_container_at(
11360 &self.state.placed_containers,
11361 self.state.character_id,
11362 x,
11363 y,
11364 ) {
11365 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
11366 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
11367 }
11368 }
11369 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11370 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
11371 consider(
11372 dist(x, y, n.x, n.y),
11373 2,
11374 Target::Npc(npc_id, label),
11375 &mut best,
11376 );
11377 }
11378 }
11379 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11380 let d = dist(x, y, node.x, node.y);
11381 consider(
11382 d,
11383 3,
11384 Target::Node(node.id.clone(), node.label.clone()),
11385 &mut best,
11386 );
11387 }
11388
11389 match best.map(|(_, _, t)| t) {
11390 Some(Target::Bed(bed_id)) => {
11391 let name = self
11392 .state
11393 .placed_containers
11394 .iter()
11395 .find(|c| c.id == bed_id)
11396 .map(|c| c.display_name.clone())
11397 .unwrap_or_else(|| "camp bed".into());
11398 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11399 ed.lodging_container_id = Some(bed_id.clone());
11400 }
11401 self.state
11402 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
11403 }
11404 Some(Target::Container(cid)) => {
11405 let name = self
11406 .state
11407 .placed_containers
11408 .iter()
11409 .find(|c| c.id == cid)
11410 .map(|c| c.display_name.clone())
11411 .unwrap_or_else(|| "container".into());
11412 let added = self
11413 .state
11414 .worker_route_editor
11415 .as_mut()
11416 .is_some_and(|ed| ed.append_deposit_at(&cid));
11417 if added {
11418 self.state
11419 .push_log(format!("Route: + deposit at {name} ({cid})"));
11420 } else {
11421 self.state.push_log(format!(
11422 "Route: {name} already in route — selected it (d to remove)"
11423 ));
11424 }
11425 }
11426 Some(Target::Npc(npc_id, label)) => {
11427 let template = self.re_template_candidates().into_iter().next();
11430 let Some(template) = template else {
11431 self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
11432 return;
11433 };
11434 let added = self
11435 .state
11436 .worker_route_editor
11437 .as_mut()
11438 .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
11439 if added {
11440 self.state
11441 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
11442 } else {
11443 self.state.push_log(format!(
11444 "Route: {label} already sells {template} — selected it (d to remove)"
11445 ));
11446 }
11447 }
11448 Some(Target::Node(id, label)) => {
11449 let added = self
11450 .state
11451 .worker_route_editor
11452 .as_mut()
11453 .is_some_and(|ed| ed.append_harvest_node(&id));
11454 if added {
11455 self.state
11456 .push_log(format!("Route: + harvest node {label} ({id})"));
11457 } else {
11458 self.state.push_log(format!(
11459 "Route: {label} already in route — selected it (d to remove)"
11460 ));
11461 }
11462 }
11463 None => {}
11464 }
11465 }
11466
11467 pub fn worker_route_editor_select(&mut self, delta: i32) {
11468 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11469 return;
11470 };
11471 if ed.stops.is_empty() {
11472 return;
11473 }
11474 let n = ed.stops.len() as i32;
11475 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
11476 ed.selected_stop_index = next;
11477 }
11478
11479 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
11480 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11481 return;
11482 };
11483 if delta < 0 {
11484 ed.move_selected_up();
11485 } else if delta > 0 {
11486 ed.move_selected_down();
11487 }
11488 }
11489
11490 pub fn worker_route_editor_delete_selected(&mut self) {
11491 let removed = self
11492 .state
11493 .worker_route_editor
11494 .as_mut()
11495 .is_some_and(|ed| {
11496 let before = ed.stop_count();
11497 ed.remove_selected_stop();
11498 ed.stop_count() < before
11499 });
11500 if removed {
11501 self.state.push_log("Route: removed selected stop");
11502 }
11503 }
11504
11505 pub fn worker_route_editor_clear_stops(&mut self) {
11508 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11509 return;
11510 };
11511 if ed.stops.is_empty() {
11512 self.state.push_log("Route: already empty — s saves an idle worker".to_string());
11513 return;
11514 }
11515 ed.stops.clear();
11516 ed.selected_stop_index = 0;
11517 self.state
11518 .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
11519 }
11520
11521 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
11522 if self.state.pending_worker_job_ack.is_some() {
11523 anyhow::bail!("route save still pending — wait for server ack");
11524 }
11525 let Some(ed) = self.state.worker_route_editor.clone() else {
11526 anyhow::bail!("route editor not open");
11527 };
11528 let (job_yaml, idle) = if ed.stops.is_empty() {
11531 (ed.build_idle_job_yaml(), true)
11532 } else {
11533 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
11534 };
11535 let worker_id = ed.worker_instance_id.clone();
11536 let route_view = if idle {
11537 None
11538 } else {
11539 Some(ed.to_route_view())
11540 };
11541 let mode = if idle {
11542 flatland_protocol::WorkerModeView::Idle
11543 } else {
11544 flatland_protocol::WorkerModeView::JobLoop
11545 };
11546 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
11547 .state
11548 .hired_workers
11549 .iter()
11550 .find(|w| w.instance_id == worker_id)
11551 .map(|w| {
11552 (
11553 w.route.clone(),
11554 w.mode,
11555 w.step_label.clone(),
11556 w.last_error.clone(),
11557 )
11558 })
11559 .unwrap_or((
11560 None,
11561 flatland_protocol::WorkerModeView::Idle,
11562 String::new(),
11563 None,
11564 ));
11565 self.seq += 1;
11566 let seq = self.seq;
11567 self.session
11568 .submit_intent(Intent::SetWorkerJob {
11569 entity_id: self.state.entity_id,
11570 worker_instance_id: worker_id.clone(),
11571 job_yaml,
11572 seq,
11573 })
11574 .await?;
11575 self.state.intents_sent += 1;
11576 if let Some(w) = self
11577 .state
11578 .hired_workers
11579 .iter_mut()
11580 .find(|w| w.instance_id == worker_id)
11581 {
11582 w.route = route_view;
11583 w.mode = mode;
11584 w.last_error = None;
11585 if idle {
11586 w.step_label.clear();
11587 w.route_stop_index = None;
11588 }
11589 }
11590 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
11591 seq,
11592 worker_instance_id: worker_id,
11593 worker_label: ed.worker_label.clone(),
11594 idle,
11595 stop_count: ed.stops.len(),
11596 prev_route,
11597 prev_mode,
11598 prev_step_label,
11599 prev_last_error,
11600 });
11601 self.state.push_log(format!(
11602 "Route: saving for {}… (waiting for server)",
11603 ed.worker_label
11604 ));
11605 Ok(())
11607 }
11608 pub fn quest_menu_move(&mut self, delta: i32) {
11609 let n = self.state.active_quest_entries().len();
11610 if n == 0 {
11611 return;
11612 }
11613 let idx = self.state.quest_menu_index as i32;
11614 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
11615 }
11616
11617 pub fn quest_menu_page(&mut self, pages: i32) {
11618 let n = self.state.active_quest_entries().len();
11619 self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
11620 }
11621
11622 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
11623 let Some(offer) = self.state.pending_quest_offer.clone() else {
11624 anyhow::bail!("no quest offer");
11625 };
11626 self.seq += 1;
11627 let seq = self.seq;
11628 self.session
11629 .submit_intent(Intent::AcceptQuest {
11630 entity_id: self.state.entity_id,
11631 quest_id: offer.quest_id,
11632 seq,
11633 })
11634 .await?;
11635 self.state.intents_sent += 1;
11636 Ok(())
11637 }
11638
11639 pub fn quest_offer_decline(&mut self) {
11640 self.state.show_quest_offer = false;
11641 self.state.pending_quest_offer = None;
11642 if !self.state.show_npc_chat
11643 && !self.state.show_shop_menu
11644 && self.state.npc_verb_target.is_some()
11645 {
11646 self.state.show_npc_verb_menu = true;
11647 }
11648 }
11649
11650 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
11651 if !self.state.show_quest_menu {
11652 return Ok(());
11653 }
11654 let active: Vec<_> = self
11655 .state
11656 .active_quest_entries()
11657 .into_iter()
11658 .cloned()
11659 .collect();
11660 let Some(entry) = active.get(self.state.quest_menu_index) else {
11661 return Ok(());
11662 };
11663 if self.state.quest_withdraw_confirm {
11664 if !entry.can_withdraw {
11665 anyhow::bail!("quest cannot be withdrawn");
11666 }
11667 self.seq += 1;
11668 let seq = self.seq;
11669 self.session
11670 .submit_intent(Intent::WithdrawQuest {
11671 entity_id: self.state.entity_id,
11672 quest_id: entry.quest_id.clone(),
11673 seq,
11674 })
11675 .await?;
11676 self.state.intents_sent += 1;
11677 self.state.quest_withdraw_confirm = false;
11678 return Ok(());
11679 }
11680 self.seq += 1;
11681 let seq = self.seq;
11682 self.session
11683 .submit_intent(Intent::TrackQuest {
11684 entity_id: self.state.entity_id,
11685 quest_id: entry.quest_id.clone(),
11686 seq,
11687 })
11688 .await?;
11689 self.state.intents_sent += 1;
11690 Ok(())
11691 }
11692
11693 pub fn quest_request_withdraw(&mut self) {
11694 if self.state.show_quest_menu {
11695 self.state.quest_withdraw_confirm = true;
11696 }
11697 }
11698
11699 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
11700 if !self.state.is_alive() {
11701 anyhow::bail!("you are dead");
11702 }
11703 let Some(catalog) = self.state.shop_catalog.clone() else {
11704 anyhow::bail!("no shop open");
11705 };
11706 self.seq += 1;
11707 let seq = self.seq;
11708 match self.state.shop_tab {
11709 ShopTab::Buy => {
11710 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
11711 anyhow::bail!("nothing selected");
11712 };
11713 if offer.already_owned {
11714 anyhow::bail!("already owned");
11715 }
11716 self.session
11717 .submit_intent(Intent::ShopBuy {
11718 entity_id: self.state.entity_id,
11719 npc_id: catalog.npc_id.clone(),
11720 offer_id: offer.offer_id.clone(),
11721 quantity: self.state.shop_quantity,
11722 seq,
11723 })
11724 .await?;
11725 }
11726 ShopTab::Sell => {
11727 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
11728 anyhow::bail!("nothing to sell");
11729 };
11730 if line.quantity == 0 {
11731 anyhow::bail!("you have no {}", line.label);
11732 }
11733 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
11734 self.session
11735 .submit_intent(Intent::ShopSell {
11736 entity_id: self.state.entity_id,
11737 npc_id: catalog.npc_id.clone(),
11738 template_id: line.template_id.clone(),
11739 quantity,
11740 seq,
11741 })
11742 .await?;
11743 }
11744 }
11745 self.state.intents_sent += 1;
11746 Ok(())
11747 }
11748
11749 pub fn craft_menu_move(&mut self, delta: i32) {
11750 let n = self.state.blueprints.len();
11751 if n == 0 {
11752 return;
11753 }
11754 let idx = self.state.craft_menu_index as i32;
11755 let next = (idx + delta).rem_euclid(n as i32);
11756 self.state.craft_menu_index = next as usize;
11757 self.state.clamp_craft_batch_quantity();
11758 }
11759
11760 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
11761 self.state.craft_batch_adjust_quantity(delta);
11762 }
11763
11764 pub fn craft_batch_set_max(&mut self) {
11765 self.state.craft_batch_set_max();
11766 }
11767
11768 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
11769 let Some(blueprint) = self
11770 .state
11771 .blueprints
11772 .get(self.state.craft_menu_index)
11773 .cloned()
11774 else {
11775 anyhow::bail!("no blueprints known");
11776 };
11777 if !self.state.can_craft_blueprint(&blueprint) {
11778 let hint = self
11779 .state
11780 .craft_missing_hint(&blueprint)
11781 .unwrap_or_else(|| "missing materials".into());
11782 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
11783 }
11784 let count = self.state.craft_batch_quantity;
11785 let max = self.state.max_craft_batches(&blueprint);
11786 if max == 0 {
11787 anyhow::bail!("cannot craft {}", blueprint.label);
11788 }
11789 let batches = count.min(max);
11790 self.craft(&blueprint.id, Some(batches)).await?;
11791 self.state.show_craft_menu = false;
11792 Ok(())
11793 }
11794
11795 pub async fn move_by(
11796 &mut self,
11797 forward: f32,
11798 strafe: f32,
11799 vertical: f32,
11800 sprint: bool,
11801 ) -> anyhow::Result<()> {
11802 if !self.state.is_alive() {
11803 anyhow::bail!("you are dead");
11804 }
11805 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
11806 self.last_move_forward = forward;
11807 self.last_move_strafe = strafe;
11808 }
11809 self.seq += 1;
11810 self.session
11811 .submit_intent(Intent::Move {
11812 entity_id: self.state.entity_id,
11813 forward,
11814 strafe,
11815 vertical,
11816 sprint,
11817 seq: self.seq,
11818 })
11819 .await?;
11820 self.state.intents_sent += 1;
11821 Ok(())
11822 }
11823
11824 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
11825 if !self.state.connected {
11826 crate::harvest_trace!("harvest_nearest rejected: not connected");
11827 anyhow::bail!("not connected");
11828 }
11829 if !self.state.is_alive() {
11830 crate::harvest_trace!("harvest_nearest rejected: player dead");
11831 anyhow::bail!("you are dead");
11832 }
11833 if self.state.harvest_in_progress {
11834 if self.state.harvest_state_stale() {
11835 self.state.clear_harvest_state();
11836 } else {
11837 anyhow::bail!("already harvesting");
11838 }
11839 }
11840 let (px, py) = self
11841 .state
11842 .player
11843 .as_ref()
11844 .map(|p| (p.transform.position.x, p.transform.position.y))
11845 .unwrap_or((0.0, 0.0));
11846
11847 let available = self
11848 .state
11849 .resource_nodes
11850 .iter()
11851 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
11852 .count();
11853 let node_id = self
11854 .state
11855 .resource_nodes
11856 .iter()
11857 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
11858 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
11859 .min_by(|a, b| {
11860 let da = distance(px, py, a.x, a.y);
11861 let db = distance(px, py, b.x, b.y);
11862 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
11863 })
11864 .map(|n| n.id.clone());
11865
11866 let Some(node_id) = node_id else {
11867 let has_loot = self
11868 .state
11869 .ground_drops
11870 .iter()
11871 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
11872 if has_loot {
11873 return self.pickup_nearest().await;
11874 }
11875 anyhow::bail!(
11876 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
11877 );
11878 };
11879
11880 self.seq += 1;
11881 let seq = self.seq;
11882 crate::harvest_trace!(
11883 entity_id = self.state.entity_id,
11884 node_id = %node_id,
11885 seq,
11886 px,
11887 py,
11888 available_nodes = available,
11889 "submitting harvest intent"
11890 );
11891 self.session
11892 .submit_intent(Intent::Harvest {
11893 entity_id: self.state.entity_id,
11894 node_id,
11895 seq,
11896 })
11897 .await?;
11898 self.state.intents_sent += 1;
11899 self.state.harvest_in_progress = true;
11900 self.state.harvest_started_at = Some(Instant::now());
11901 self.state.push_log("Harvesting…");
11902 crate::harvest_trace!(
11903 entity_id = self.state.entity_id,
11904 seq,
11905 "harvest intent queued to session"
11906 );
11907 Ok(())
11908 }
11909
11910 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
11911 if !self.state.is_alive() {
11912 anyhow::bail!("you are dead");
11913 }
11914 let blueprint_id = self
11915 .state
11916 .blueprints
11917 .iter()
11918 .find(|bp| self.state.can_craft_blueprint(bp))
11919 .map(|bp| bp.id.clone())
11920 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
11921 self.craft(&blueprint_id, None).await
11922 }
11923
11924 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
11925 if !self.state.is_alive() {
11926 anyhow::bail!("you are dead");
11927 }
11928 self.seq += 1;
11929 self.session
11930 .submit_intent(Intent::Craft {
11931 entity_id: self.state.entity_id,
11932 blueprint_id: blueprint_id.to_string(),
11933 count,
11934 seq: self.seq,
11935 })
11936 .await?;
11937 self.state.intents_sent += 1;
11938 let (label, batches) = self
11939 .state
11940 .blueprints
11941 .iter()
11942 .find(|b| b.id == blueprint_id)
11943 .map(|b| {
11944 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
11945 (b.label.as_str(), n)
11946 })
11947 .unwrap_or((blueprint_id, count.unwrap_or(1)));
11948 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
11949 Ok(())
11950 }
11951
11952 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
11953 if !self.state.is_alive() {
11954 anyhow::bail!("you are dead");
11955 }
11956 let target_id = match self.state.nearest_interact_target() {
11957 Some(id) => id,
11958 None => {
11959 anyhow::bail!("nothing to interact with nearby");
11960 }
11961 };
11962 if self.state.npcs.iter().any(|n| n.id == target_id) {
11963 self.state.show_npc_verb_menu = true;
11964 self.state.npc_verb_target = Some(target_id);
11965 self.state.npc_verb_index = 0;
11966 return Ok(());
11967 }
11968 if self
11969 .state
11970 .hired_workers
11971 .iter()
11972 .any(|w| w.instance_id == target_id)
11973 {
11974 return self.open_workers_menu_for(&target_id).await;
11975 }
11976 if let Ok(peer_id) = target_id.parse::<EntityId>() {
11977 if self
11978 .state
11979 .hired_workers
11980 .iter()
11981 .any(|w| w.entity_id == peer_id)
11982 {
11983 if let Some(w) = self
11984 .state
11985 .hired_workers
11986 .iter()
11987 .find(|w| w.entity_id == peer_id)
11988 {
11989 let id = w.instance_id.clone();
11990 return self.open_workers_menu_for(&id).await;
11991 }
11992 }
11993 if let Some(entity) = self
11994 .state
11995 .entities
11996 .iter()
11997 .find(|e| e.id == peer_id && e.id != self.state.entity_id)
11998 {
11999 self.state
12000 .player_verbs
12001 .open_for(peer_id, &entity.label);
12002 return Ok(());
12003 }
12004 }
12005 self.seq += 1;
12006 self.session
12007 .submit_intent(Intent::Interact {
12008 entity_id: self.state.entity_id,
12009 target_id: target_id.clone(),
12010 seq: self.seq,
12011 })
12012 .await?;
12013 self.state.intents_sent += 1;
12014 Ok(())
12015 }
12016
12017 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
12019 if !self.state.is_alive() {
12020 anyhow::bail!("you are dead");
12021 }
12022 let (px, py) = self.state.player_position();
12023 let has_loot = self
12024 .state
12025 .ground_drops
12026 .iter()
12027 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
12028 if has_loot {
12029 return self.pickup_nearest().await;
12030 }
12031 if self
12032 .state
12033 .placed_containers
12034 .iter()
12035 .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
12036 {
12037 return self.pickup_nearest_container().await;
12038 }
12039
12040 if let Some(plot) = self.state.my_plot_under_player().cloned() {
12041 const SELL_WINDOW: Duration = Duration::from_millis(1200);
12043 let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
12044 && self
12045 .state
12046 .sell_plot_armed_at
12047 .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
12048 if sell_armed {
12049 return self.confirm_sell_plot_to_crown(plot.plot_id).await;
12050 }
12051 self.state.sell_plot_confirm = None;
12052 self.state.sell_plot_armed_at = None;
12053
12054 let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
12057 self.state.npcs.iter().any(|n| n.id == id)
12058 || self.state.hired_workers.iter().any(|w| w.instance_id == id)
12059 || self.state.doors.iter().any(|d| d.id == id)
12060 || self.state.interactables.iter().any(|i| {
12061 i.id == id
12062 && matches!(
12063 i.kind.as_str(),
12064 "quest_board" | "well" | "exit" | "enter"
12065 )
12066 })
12067 || id.parse::<EntityId>().is_ok_and(|eid| {
12068 self.state
12069 .entities
12070 .iter()
12071 .any(|e| e.id == eid && e.id != self.state.entity_id)
12072 })
12073 });
12074 if !blocking_interact {
12075 match self.harvest_nearest().await {
12077 Ok(()) => return Ok(()),
12078 Err(err) => {
12079 let msg = err.to_string();
12080 if !(msg.contains("no harvestable")
12081 || msg.contains("press p")
12082 || msg.contains("press f")
12083 || msg.contains("nothing"))
12084 {
12085 return Err(err);
12086 }
12087 }
12088 }
12089 return Ok(());
12090 }
12091 }
12092 if self.state.nearest_interact_target().is_some() {
12093 return self.interact_nearest().await;
12094 }
12095 if let Some((label, dist)) = self.state.nearest_quest_board() {
12098 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
12099 anyhow::bail!(
12100 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
12101 );
12102 }
12103 }
12104
12105 match self.harvest_nearest().await {
12106 Ok(()) => Ok(()),
12107 Err(err) => {
12108 let msg = err.to_string();
12109 if msg.contains("no harvestable")
12110 || msg.contains("press p")
12111 || msg.contains("press f")
12112 {
12113 anyhow::bail!(
12114 "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
12115 );
12116 }
12117 Err(err)
12118 }
12119 }
12120 }
12121
12122 pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
12124 if !self.state.is_alive() {
12125 anyhow::bail!("you are dead");
12126 }
12127 if self.state.claim_mode.is_some() {
12128 anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
12129 }
12130 let zone = self
12131 .state
12132 .free_property_zone_under_player()
12133 .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
12134 let zone_id = zone.id.clone();
12135 let label = zone
12136 .label
12137 .as_deref()
12138 .filter(|s| !s.trim().is_empty())
12139 .unwrap_or(zone.id.as_str())
12140 .to_string();
12141 self.enter_claim_mode(&zone_id);
12142 self.state
12143 .push_log(format!(
12144 "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
12145 ));
12146 Ok(())
12147 }
12148
12149 pub fn enter_claim_mode(&mut self, zone_id: &str) {
12151 let Some(zone) = self
12152 .state
12153 .property_zones
12154 .iter()
12155 .find(|z| z.id == zone_id)
12156 .cloned()
12157 else {
12158 self.state.push_log("unknown property zone");
12159 return;
12160 };
12161 self.state.sell_plot_confirm = None;
12162 self.state.sell_plot_armed_at = None;
12163 let min_area = self
12164 .state
12165 .property_plot_settings
12166 .as_ref()
12167 .map(|s| s.min_plot_area_m2)
12168 .unwrap_or(4.0)
12169 .max(1.0);
12170 let min_side = min_area.sqrt().ceil().max(1.0) as u32;
12171 let side = 4u32.max(min_side);
12172 let (px, py) = self.state.player_position();
12173 let anchor_x = px.floor();
12174 let anchor_y = py.floor();
12175 self.state.claim_mode = Some(ClaimModeState {
12176 zone_id: zone.id.clone(),
12177 width_m: side,
12178 height_m: side,
12179 anchor_x,
12180 anchor_y,
12181 });
12182 let label = zone
12183 .label
12184 .as_deref()
12185 .filter(|s| !s.trim().is_empty())
12186 .unwrap_or(zone.id.as_str());
12187 self.state.push_log(format!(
12188 "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
12189 ));
12190 }
12191
12192 pub fn cancel_claim_mode(&mut self) {
12193 if self.state.claim_mode.take().is_some() {
12194 self.state.push_log("Claim cancelled");
12195 }
12196 }
12197
12198 pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
12200 if !self.state.is_alive() {
12201 anyhow::bail!("you are dead");
12202 }
12203 if self.state.relocate_mode.is_some() {
12204 anyhow::bail!("already relocating — Enter confirm, Esc cancel");
12205 }
12206 if self.state.claim_mode.is_some() {
12207 anyhow::bail!("finish or cancel claim mode first");
12208 }
12209 let chest = self
12210 .state
12211 .placed_containers
12212 .iter()
12213 .find(|c| c.id == container_id)
12214 .cloned()
12215 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
12216 let (px, py) = self.state.player_position();
12217 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
12218 anyhow::bail!("too far from {}", chest.display_name);
12219 }
12220 if chest.locked && !chest.accessible {
12221 anyhow::bail!(
12222 "need the matching key for {} before moving it",
12223 chest.display_name
12224 );
12225 }
12226 let label = if chest.display_name.trim().is_empty() {
12227 chest.template_id.clone()
12228 } else {
12229 chest.display_name.clone()
12230 };
12231 self.state.relocate_mode = Some(RelocateModeState {
12232 container_id: chest.id.clone(),
12233 label: label.clone(),
12234 cursor_x: chest.x.floor() + 0.5,
12235 cursor_y: chest.y.floor() + 0.5,
12236 });
12237 self.state.push_log(format!(
12238 "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
12239 ));
12240 Ok(())
12241 }
12242
12243 pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
12245 let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
12246 anyhow::bail!("no chest nearby to relocate");
12247 };
12248 if chest.locked && !chest.accessible {
12249 anyhow::bail!(
12250 "need the matching key for {} before moving it",
12251 chest.display_name
12252 );
12253 }
12254 self.begin_relocate_container(&chest.id)
12257 }
12258
12259 pub fn cancel_relocate_mode(&mut self) {
12260 if self.state.relocate_mode.take().is_some() {
12261 self.state.push_log("Relocate cancelled");
12262 }
12263 }
12264
12265 pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
12266 let Some(mode) = self.state.relocate_mode.as_mut() else {
12267 return;
12268 };
12269 let max_x = self.state.world_width_m.max(1.0);
12270 let max_y = self.state.world_height_m.max(1.0);
12271 let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
12272 let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
12273 mode.cursor_x = nx.floor() + 0.5;
12274 mode.cursor_y = ny.floor() + 0.5;
12275 }
12276
12277 pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
12278 let Some(mode) = self.state.relocate_mode.as_mut() else {
12279 return;
12280 };
12281 let max_x = self.state.world_width_m.max(1.0);
12282 let max_y = self.state.world_height_m.max(1.0);
12283 mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
12284 mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
12285 }
12286
12287 pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
12288 if !self.state.is_alive() {
12289 anyhow::bail!("you are dead");
12290 }
12291 let Some(mode) = self.state.relocate_mode.clone() else {
12292 anyhow::bail!("not relocating");
12293 };
12294 let (px, py) = self.state.player_position();
12295 let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
12296 if dist > 8.0 {
12297 anyhow::bail!("destination too far (max 8 m)");
12298 }
12299 self.seq += 1;
12300 self.session
12301 .submit_intent(Intent::MovePlacedContainer {
12302 entity_id: self.state.entity_id,
12303 container_id: mode.container_id.clone(),
12304 x: mode.cursor_x,
12305 y: mode.cursor_y,
12306 seq: self.seq,
12307 })
12308 .await?;
12309 self.state.intents_sent += 1;
12310 self.state.relocate_mode = None;
12311 self.state
12312 .push_log(format!("Moving {}…", mode.label));
12313 Ok(())
12314 }
12315
12316 pub fn claim_set_preset(&mut self, w: u32, h: u32) {
12317 let Some(mode) = self.state.claim_mode.as_mut() else {
12318 return;
12319 };
12320 mode.width_m = w.max(1);
12321 mode.height_m = h.max(1);
12322 }
12323
12324 pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
12325 let Some(mode) = self.state.claim_mode.as_mut() else {
12326 return;
12327 };
12328 let w = (mode.width_m as i32 + dw).max(1) as u32;
12329 let h = (mode.height_m as i32 + dh).max(1) as u32;
12330 mode.width_m = w;
12331 mode.height_m = h;
12332 }
12333
12334 pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
12336 let Some(mode) = self.state.claim_mode.as_mut() else {
12337 return;
12338 };
12339 let max_x = self.state.world_width_m.max(1.0);
12340 let max_y = self.state.world_height_m.max(1.0);
12341 let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
12342 let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
12343 mode.anchor_x = nx.floor();
12344 mode.anchor_y = ny.floor();
12345 }
12346
12347 pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
12348 if !self.state.is_alive() {
12349 anyhow::bail!("you are dead");
12350 }
12351 let Some(mode) = self.state.claim_mode.clone() else {
12352 anyhow::bail!("not in claim mode");
12353 };
12354 let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
12355 self.state.claim_quote()
12356 else {
12357 anyhow::bail!("cannot quote claim");
12358 };
12359 if !valid {
12360 anyhow::bail!(reason);
12361 }
12362 if !can_afford {
12363 anyhow::bail!(
12364 "not enough copper (need {})",
12365 crate::currency::format_copper(purchase)
12366 );
12367 }
12368 let (x0, y0, x1, y1) = self
12369 .state
12370 .claim_footprint_rect()
12371 .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
12372 let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
12373 self.seq += 1;
12374 self.session
12375 .submit_intent(Intent::BuyPlot {
12376 entity_id: self.state.entity_id,
12377 zone_id: mode.zone_id,
12378 x0,
12379 y0,
12380 x1,
12381 y1,
12382 seq: self.seq,
12383 })
12384 .await?;
12385 self.state.intents_sent += 1;
12386 self.state.claim_mode = None;
12387 self.state
12388 .push_log(format!("Buying plot for {}", crate::currency::format_copper(purchase)));
12389 Ok(())
12390 }
12391
12392 pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
12393 if !self.state.is_alive() {
12394 anyhow::bail!("you are dead");
12395 }
12396 let zone_id = self
12397 .state
12398 .claim_mode
12399 .as_ref()
12400 .map(|m| m.zone_id.clone())
12401 .or_else(|| {
12402 self.state
12403 .free_property_zone_under_player()
12404 .map(|z| z.id.clone())
12405 })
12406 .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
12407 self.seq += 1;
12408 self.session
12409 .submit_intent(Intent::BuyPlotAllFree {
12410 entity_id: self.state.entity_id,
12411 zone_id,
12412 seq: self.seq,
12413 })
12414 .await?;
12415 self.state.intents_sent += 1;
12416 self.state.claim_mode = None;
12417 self.state.push_log("Claiming largest free plot…");
12418 Ok(())
12419 }
12420
12421 pub async fn confirm_sell_plot_to_crown(
12422 &mut self,
12423 plot_id: uuid::Uuid,
12424 ) -> anyhow::Result<()> {
12425 if !self.state.is_alive() {
12426 anyhow::bail!("you are dead");
12427 }
12428 self.seq += 1;
12429 self.session
12430 .submit_intent(Intent::SellPlotToCrown {
12431 entity_id: self.state.entity_id,
12432 plot_id,
12433 seq: self.seq,
12434 })
12435 .await?;
12436 self.state.intents_sent += 1;
12437 self.state.sell_plot_confirm = None;
12438 self.state.sell_plot_armed_at = None;
12439 self.state.push_log("Selling plot to the crown…");
12440 Ok(())
12441 }
12442
12443 pub async fn set_plot_farm_public(
12444 &mut self,
12445 plot_id: uuid::Uuid,
12446 public: bool,
12447 public_tax_discount_bps: u32,
12448 ) -> anyhow::Result<()> {
12449 self.seq += 1;
12450 self.session
12451 .submit_intent(Intent::SetPlotFarmPublic {
12452 entity_id: self.state.entity_id,
12453 plot_id,
12454 public,
12455 public_tax_discount_bps,
12456 seq: self.seq,
12457 })
12458 .await?;
12459 self.state.intents_sent += 1;
12460 Ok(())
12461 }
12462
12463 pub async fn plot_farm_allow_upsert(
12464 &mut self,
12465 plot_id: uuid::Uuid,
12466 character_id: Option<uuid::Uuid>,
12467 character_name: String,
12468 tax_discount_bps: u32,
12469 ) -> anyhow::Result<()> {
12470 self.seq += 1;
12471 self.session
12472 .submit_intent(Intent::PlotFarmAllowUpsert {
12473 entity_id: self.state.entity_id,
12474 plot_id,
12475 character_id,
12476 character_name,
12477 tax_discount_bps,
12478 seq: self.seq,
12479 })
12480 .await?;
12481 self.state.intents_sent += 1;
12482 Ok(())
12483 }
12484
12485 pub async fn plot_farm_allow_remove(
12486 &mut self,
12487 plot_id: uuid::Uuid,
12488 character_id: uuid::Uuid,
12489 ) -> anyhow::Result<()> {
12490 self.seq += 1;
12491 self.session
12492 .submit_intent(Intent::PlotFarmAllowRemove {
12493 entity_id: self.state.entity_id,
12494 plot_id,
12495 character_id,
12496 seq: self.seq,
12497 })
12498 .await?;
12499 self.state.intents_sent += 1;
12500 Ok(())
12501 }
12502
12503 pub fn open_farm_access_panel(&mut self) {
12504 let Some(plot) = self.state.my_plot_under_player() else {
12505 self.state
12506 .push_log("Stand on your deed plot to manage farm access");
12507 return;
12508 };
12509 self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
12510 self.state.farm_access_index = 0;
12511 self.state.show_farm_access = true;
12512 }
12513
12514 pub fn close_farm_access_panel(&mut self) {
12515 self.state.show_farm_access = false;
12516 self.state.farm_access_name_draft.clear();
12517 self.state.farm_access_index = 0;
12518 }
12519
12520 pub fn farm_access_move(&mut self, delta: i32) {
12521 let n = self.farm_access_row_count().max(1);
12522 let idx = self.state.farm_access_index as i32 + delta;
12523 self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
12524 }
12525
12526 pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
12527 let Some(plot) = self.state.my_plot_under_player() else {
12528 return vec![FarmAccessRow::PublicToggle];
12529 };
12530 let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
12531 for g in &plot.farm_allow {
12532 rows.push(FarmAccessRow::AllowRemove {
12533 character_id: g.character_id,
12534 label: if g.character_label.trim().is_empty() {
12535 g.character_id.to_string()[..8].to_string()
12536 } else {
12537 g.character_label.clone()
12538 },
12539 tax_discount_bps: g.tax_discount_bps,
12540 });
12541 }
12542 for e in &self.state.entities {
12543 if e.id == self.state.entity_id || e.label.trim().is_empty() {
12544 continue;
12545 }
12546 if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
12547 continue;
12548 }
12549 if self
12550 .state
12551 .npcs
12552 .iter()
12553 .any(|n| n.id == e.label || n.label == e.label)
12554 {
12555 continue;
12556 }
12557 if plot
12558 .farm_allow
12559 .iter()
12560 .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
12561 {
12562 continue;
12563 }
12564 rows.push(FarmAccessRow::NearbyAdd {
12565 name: e.label.clone(),
12566 });
12567 }
12568 rows
12569 }
12570
12571 pub fn farm_access_row_count(&self) -> usize {
12572 self.farm_access_rows().len().max(1)
12573 }
12574
12575 pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
12576 let Some(plot) = self.state.my_plot_under_player().cloned() else {
12577 self.close_farm_access_panel();
12578 return Ok(());
12579 };
12580 let rows = self.farm_access_rows();
12581 let Some(row) = rows.get(self.state.farm_access_index) else {
12582 return Ok(());
12583 };
12584 match row {
12585 FarmAccessRow::PublicToggle => {
12586 self.set_plot_farm_public(
12587 plot.plot_id,
12588 !plot.farm_public,
12589 plot.public_tax_discount_bps,
12590 )
12591 .await
12592 }
12593 FarmAccessRow::PublicDiscount => Ok(()),
12594 FarmAccessRow::AllowRemove { character_id, .. } => {
12595 self.plot_farm_allow_remove(plot.plot_id, *character_id)
12596 .await
12597 }
12598 FarmAccessRow::NearbyAdd { name } => {
12599 let disc = self
12600 .state
12601 .farm_access_discount_bps
12602 .max(plot.public_tax_discount_bps);
12603 self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
12604 .await
12605 }
12606 }
12607 }
12608
12609 pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
12610 let Some(plot) = self.state.my_plot_under_player().cloned() else {
12611 return Ok(());
12612 };
12613 let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
12614 self.state.farm_access_discount_bps = next;
12615 self.state.farm_access_index = 1;
12616 self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
12617 .await
12618 }
12619
12620 pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
12622 if self.state.farmable_plot_under_player().is_none() {
12623 anyhow::bail!("stand on a farmable plot to cultivate");
12624 }
12625 let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
12626 let (px, py) = self.state.player_position();
12627 if self
12628 .state
12629 .terrain_at(px, py)
12630 .is_some_and(|k| k == TerrainKindView::Tilled)
12631 {
12632 anyhow::bail!("already tilled — stand on bare soil and press c");
12633 }
12634 anyhow::bail!("cannot till this cell — move onto soil on your plot");
12635 };
12636 self.cultivate_at(tx, ty).await
12637 }
12638
12639 pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
12641 if self.state.farmable_plot_under_player().is_none() {
12642 anyhow::bail!("stand on a farmable plot to plant");
12643 }
12644 if !self.state.underfoot_free_tilled_plant_slot() {
12645 anyhow::bail!("stand on empty tilled soil and press p");
12646 }
12647 let seeds = self.state.farm_seed_entries();
12648 if seeds.is_empty() {
12649 anyhow::bail!("no seeds in inventory — buy seeds from Eli");
12650 }
12651 if seeds.len() == 1 {
12652 return self.plant_seeds(seeds[0].0.clone(), 1).await;
12653 }
12654 self.open_plant_menu();
12655 Ok(())
12656 }
12657
12658 pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
12659 if !self.state.is_alive() {
12660 anyhow::bail!("you are dead");
12661 }
12662 self.seq += 1;
12663 self.session
12664 .submit_intent(Intent::Cultivate {
12665 entity_id: self.state.entity_id,
12666 x,
12667 y,
12668 seq: self.seq,
12669 })
12670 .await?;
12671 self.state.intents_sent += 1;
12672 Ok(())
12673 }
12674
12675 pub async fn plant_seeds(
12676 &mut self,
12677 seed_template_id: String,
12678 quantity: u32,
12679 ) -> anyhow::Result<()> {
12680 if !self.state.is_alive() {
12681 anyhow::bail!("you are dead");
12682 }
12683 self.seq += 1;
12684 self.session
12685 .submit_intent(Intent::PlantSeeds {
12686 entity_id: self.state.entity_id,
12687 seed_template_id: seed_template_id.clone(),
12688 quantity,
12689 seq: self.seq,
12690 })
12691 .await?;
12692 self.state.intents_sent += 1;
12693 self.state
12694 .push_log(format!("Planting {quantity}× {seed_template_id}…"));
12695 Ok(())
12696 }
12697
12698 pub fn open_plant_menu(&mut self) {
12699 if self.state.farm_seed_entries().is_empty() {
12700 self.state.push_log("No seeds in inventory to plant");
12701 return;
12702 }
12703 self.state.show_plant_menu = true;
12704 self.state.plant_menu_index = 0;
12705 self.state.plant_quantity = 1;
12706 self.state.clamp_plant_menu();
12707 }
12708
12709 pub fn close_plant_menu(&mut self) {
12710 self.state.show_plant_menu = false;
12711 }
12712
12713 pub fn plant_menu_move(&mut self, delta: i32) {
12714 let n = self.state.farm_seed_entries().len();
12715 if n == 0 {
12716 return;
12717 }
12718 let idx = self.state.plant_menu_index as i32 + delta;
12719 self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
12720 self.state.clamp_plant_menu();
12721 }
12722
12723 pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
12724 let next = self.state.plant_quantity as i32 + delta;
12725 self.state.plant_quantity = next.max(1) as u32;
12726 self.state.clamp_plant_menu();
12727 }
12728
12729 pub fn plant_menu_set_quantity_max(&mut self) {
12730 if let Some((_, max, _)) = self.state.plant_menu_selection() {
12731 self.state.plant_quantity = max;
12732 }
12733 self.state.clamp_plant_menu();
12734 }
12735
12736 pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
12737 let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
12738 self.close_plant_menu();
12739 anyhow::bail!("no seeds to plant");
12740 };
12741 self.close_plant_menu();
12742 self.plant_seeds(seed, qty).await?;
12743 self.state.push_log(format!("Planted {qty}× {label}"));
12744 Ok(())
12745 }
12746
12747 pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
12750 if !self.state.is_alive() {
12751 anyhow::bail!("you are dead");
12752 }
12753 let binding = self
12754 .state
12755 .hotbar_ability(slot)
12756 .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
12757 .to_string();
12758 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
12759 let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
12760 if qty == 0 {
12761 anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
12762 }
12763 return self.use_item(template_id).await;
12764 }
12765 let ability_id = binding;
12766 if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
12767 return self
12768 .cast_ability(&ability_id, Some(self.state.entity_id))
12769 .await;
12770 }
12771 let is_heal = ability_id == "heal_touch"
12772 || self
12773 .state
12774 .ability_meta
12775 .get(&ability_id)
12776 .map(|meta| meta.is_heal)
12777 .unwrap_or(false);
12778 let target = if is_heal {
12779 Some(
12780 self.state
12781 .target_for_slot(2)
12782 .unwrap_or(self.state.entity_id),
12783 )
12784 } else {
12785 self.state
12786 .target_for_slot(1)
12787 .or_else(|| self.state.target_for_slot(2))
12788 };
12789 let Some(target_id) = target else {
12790 anyhow::bail!("no target — Tab to select, then press the hotbar key");
12791 };
12792 self.cast_ability(&ability_id, Some(target_id)).await
12793 }
12794
12795 pub async fn set_hotbar_slot(
12798 &mut self,
12799 slot: u8,
12800 ability_id: Option<&str>,
12801 ) -> anyhow::Result<()> {
12802 if !self.state.is_alive() {
12803 anyhow::bail!("you are dead");
12804 }
12805 if !(1..=9).contains(&slot) {
12806 anyhow::bail!("hotbar slot must be 1–9");
12807 }
12808 let ability_id = ability_id
12809 .map(str::trim)
12810 .filter(|id| !id.is_empty())
12811 .map(str::to_string);
12812 self.seq += 1;
12813 self.session
12814 .submit_intent(Intent::SetHotbarSlot {
12815 entity_id: self.state.entity_id,
12816 slot,
12817 ability_id: ability_id.clone(),
12818 seq: self.seq,
12819 })
12820 .await?;
12821 self.state.intents_sent += 1;
12822 let idx = (slot - 1) as usize;
12823 if self.state.hotbar.len() < 9 {
12824 self.state.hotbar.resize(9, None);
12825 }
12826 if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
12827 *slot_mut = ability_id.clone();
12828 }
12829 match ability_id {
12830 Some(id) => {
12831 let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
12832 format!("use {tid}")
12833 } else {
12834 id
12835 };
12836 self.state.push_log(format!("Hotbar {slot} → {label}"))
12837 }
12838 None => self.state.push_log(format!("Hotbar {slot} cleared")),
12839 }
12840 Ok(())
12841 }
12842
12843 pub fn npc_verb_options(&self) -> Vec<&'static str> {
12844 self.state.npc_verb_options()
12845 }
12846
12847 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
12848 let Some(npc_id) = self.state.npc_verb_target.clone() else {
12849 return Ok(());
12850 };
12851 let options = self.npc_verb_options();
12852 let choice = options
12853 .get(self.state.npc_verb_index)
12854 .copied()
12855 .unwrap_or("Talk");
12856 self.seq += 1;
12857 match choice {
12858 "Trade" | "Bank" | "Storage" | "Market" => {
12859 self.session
12860 .submit_intent(Intent::Interact {
12861 entity_id: self.state.entity_id,
12862 target_id: npc_id,
12863 seq: self.seq,
12864 })
12865 .await?;
12866 }
12867 _ => {
12868 self.session
12869 .submit_intent(Intent::NpcTalkOpen {
12870 entity_id: self.state.entity_id,
12871 npc_id,
12872 seq: self.seq,
12873 })
12874 .await?;
12875 }
12876 }
12877 self.state.intents_sent += 1;
12878 Ok(())
12879 }
12880
12881 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
12882 let Some(chat) = self.state.npc_chat.clone() else {
12883 return Ok(());
12884 };
12885 let message = chat.input.trim().to_string();
12886 if message.is_empty() || chat.pending {
12887 return Ok(());
12888 }
12889 if let Some(c) = self.state.npc_chat.as_mut() {
12890 c.lines.push(format!("You: {message}"));
12891 c.input.clear();
12892 c.pending = true;
12893 }
12894 self.seq += 1;
12895 self.session
12896 .submit_intent(Intent::NpcTalkSay {
12897 entity_id: self.state.entity_id,
12898 npc_id: chat.npc_id,
12899 message,
12900 seq: self.seq,
12901 })
12902 .await?;
12903 self.state.intents_sent += 1;
12904 Ok(())
12905 }
12906
12907 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
12908 let return_to_verbs = self.state.npc_verb_target.is_some();
12909 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
12910 self.state.show_npc_chat = false;
12911 if return_to_verbs {
12912 self.state.show_npc_verb_menu = true;
12913 }
12914 return Ok(());
12915 };
12916 self.seq += 1;
12917 self.session
12918 .submit_intent(Intent::NpcTalkClose {
12919 entity_id: self.state.entity_id,
12920 npc_id,
12921 seq: self.seq,
12922 })
12923 .await?;
12924 self.state.intents_sent += 1;
12925 self.state.show_npc_chat = false;
12926 self.state.npc_chat = None;
12927 if return_to_verbs {
12928 self.state.show_npc_verb_menu = true;
12929 }
12930 Ok(())
12931 }
12932
12933 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
12935 if self.state.show_quest_offer
12936 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
12937 {
12938 self.quest_offer_decline();
12939 return Ok(());
12940 }
12941 if self.state.show_npc_chat {
12942 return self.npc_talk_close().await;
12943 }
12944 if self.state.show_shop_menu {
12945 return self.back_from_shop_menu().await;
12946 }
12947 if self.state.bank_panel.is_some() {
12948 if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
12949 self.bank_transfer_back();
12950 return Ok(());
12951 }
12952 return self.close_bank_panel().await;
12953 }
12954 if self.state.storage_panel.is_some() {
12955 if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
12956 self.storage_ui_back();
12957 return Ok(());
12958 }
12959 return self.close_storage_panel().await;
12960 }
12961 if self.state.market_panel.is_some() {
12962 if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
12963 self.market_ui_back();
12964 return Ok(());
12965 }
12966 if self.state.market_buy_confirm.is_some() {
12967 self.state.market_buy_confirm = None;
12968 return Ok(());
12969 }
12970 return self.close_market_panel().await;
12971 }
12972 if self.state.show_npc_verb_menu {
12973 self.state.show_npc_verb_menu = false;
12974 self.state.npc_verb_target = None;
12975 }
12976 Ok(())
12977 }
12978
12979 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
12980 self.seq += 1;
12981 self.session
12982 .submit_intent(Intent::TestDamage {
12983 entity_id: self.state.entity_id,
12984 amount,
12985 seq: self.seq,
12986 })
12987 .await?;
12988 self.state.intents_sent += 1;
12989 Ok(())
12990 }
12991
12992 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
12993 self.cycle_combat_target_slot(1, reverse).await
12994 }
12995
12996 pub async fn cycle_combat_target_slot(
12997 &mut self,
12998 slot_index: u8,
12999 reverse: bool,
13000 ) -> anyhow::Result<()> {
13001 if !self.state.is_alive() {
13002 anyhow::bail!("you are dead");
13003 }
13004 let candidates = self.state.candidates_for_slot(slot_index);
13005 if candidates.is_empty() {
13006 anyhow::bail!("no targets nearby");
13007 }
13008 let current = self.state.target_for_slot(slot_index);
13009 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
13010 let next_idx = match idx {
13011 None => 0,
13012 Some(i) if reverse => {
13013 if i == 0 {
13014 candidates.len() - 1
13015 } else {
13016 i - 1
13017 }
13018 }
13019 Some(i) => (i + 1) % candidates.len(),
13020 };
13021 if idx == Some(next_idx) && candidates.len() == 1 {
13022 self.clear_combat_target_slot(slot_index).await?;
13023 return Ok(());
13024 }
13025 let (target_id, label) = candidates[next_idx].clone();
13026 self.set_combat_target_slot(slot_index, target_id, &label)
13027 .await
13028 }
13029
13030 pub async fn set_combat_target_slot(
13031 &mut self,
13032 slot_index: u8,
13033 target_id: EntityId,
13034 label: &str,
13035 ) -> anyhow::Result<()> {
13036 if !self.state.is_alive() {
13037 anyhow::bail!("you are dead");
13038 }
13039 self.seq += 1;
13040 self.session
13041 .submit_intent(Intent::SetTargetSlot {
13042 entity_id: self.state.entity_id,
13043 slot_index,
13044 target_id,
13045 seq: self.seq,
13046 })
13047 .await?;
13048 self.state.intents_sent += 1;
13049 if slot_index == 1 {
13050 self.state.combat_target = Some(target_id);
13051 self.state.combat_target_label = Some(label.to_string());
13052 }
13053 self.state
13054 .push_log(format!("Slot {slot_index} target: {label}"));
13055 Ok(())
13056 }
13057
13058 pub async fn set_combat_target(
13059 &mut self,
13060 target_id: EntityId,
13061 label: &str,
13062 ) -> anyhow::Result<()> {
13063 self.set_combat_target_slot(1, target_id, label).await
13064 }
13065
13066 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
13067 if slot_index == 1 && self.state.combat_target.is_none() {
13068 return Ok(());
13069 }
13070 self.seq += 1;
13071 self.session
13072 .submit_intent(Intent::ClearTargetSlot {
13073 entity_id: self.state.entity_id,
13074 slot_index,
13075 seq: self.seq,
13076 })
13077 .await?;
13078 if slot_index == 1 {
13079 self.state.combat_target = None;
13080 self.state.combat_target_label = None;
13081 }
13082 self.state.intents_sent += 1;
13083 self.state
13084 .push_log(format!("Slot {slot_index} target cleared"));
13085 Ok(())
13086 }
13087
13088 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
13089 self.clear_combat_target_slot(1).await
13090 }
13091
13092 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
13093 if !self.state.is_alive() {
13094 anyhow::bail!("you are dead");
13095 }
13096 self.seq += 1;
13097 self.session
13098 .submit_intent(Intent::AdvanceRotation {
13099 entity_id: self.state.entity_id,
13100 slot_index,
13101 seq: self.seq,
13102 })
13103 .await?;
13104 self.state.intents_sent += 1;
13105 Ok(())
13106 }
13107
13108 pub async fn assign_slot_preset(
13109 &mut self,
13110 slot_index: u8,
13111 preset_id: &str,
13112 ) -> anyhow::Result<()> {
13113 if !self.state.is_alive() {
13114 anyhow::bail!("you are dead");
13115 }
13116 self.seq += 1;
13117 self.session
13118 .submit_intent(Intent::AssignSlotPreset {
13119 entity_id: self.state.entity_id,
13120 slot_index,
13121 preset_id: preset_id.to_string(),
13122 seq: self.seq,
13123 })
13124 .await?;
13125 self.state.intents_sent += 1;
13126 if let Some(slot) = self
13127 .state
13128 .combat_slots
13129 .iter_mut()
13130 .find(|s| s.slot_index == slot_index)
13131 {
13132 slot.preset_id = Some(preset_id.to_string());
13133 if let Some(preset) = self
13134 .state
13135 .rotation_presets
13136 .iter()
13137 .find(|p| p.id == preset_id)
13138 {
13139 slot.preset_label = Some(preset.label.clone());
13140 slot.rotation = preset.abilities.clone();
13141 slot.rotation_index = 0;
13142 }
13143 }
13144 self.state
13145 .push_log(format!("T{slot_index} loadout → {preset_id}"));
13146 Ok(())
13147 }
13148
13149 pub async fn cast_ability(
13150 &mut self,
13151 ability_id: &str,
13152 target_id: Option<EntityId>,
13153 ) -> anyhow::Result<()> {
13154 if !self.state.is_alive() {
13155 anyhow::bail!("you are dead");
13156 }
13157 let allows_ground = self.state.ability_allows_ground(ability_id);
13158 let requires_ground = self.state.ability_requires_ground(ability_id);
13159 if requires_ground && self.state.ground_target.is_none() {
13160 anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
13161 }
13162 let (resolved_target_id, target_point) = if allows_ground {
13163 if let Some((x, y, z)) = self.state.ground_target {
13164 (
13165 target_id.unwrap_or(self.state.entity_id),
13166 Some(flatland_protocol::AimPoint { x, y, z }),
13167 )
13168 } else {
13169 (
13170 target_id
13171 .or_else(|| self.state.target_for_slot(2))
13172 .or_else(|| self.state.target_for_slot(1))
13173 .unwrap_or(self.state.entity_id),
13174 None,
13175 )
13176 }
13177 } else {
13178 (
13179 target_id
13180 .or_else(|| self.state.target_for_slot(2))
13181 .or_else(|| self.state.target_for_slot(1))
13182 .unwrap_or(self.state.entity_id),
13183 None,
13184 )
13185 };
13186 self.seq += 1;
13187 self.session
13188 .submit_intent(Intent::Cast {
13189 entity_id: self.state.entity_id,
13190 ability_id: ability_id.to_string(),
13191 target_id: resolved_target_id,
13192 target_point,
13193 seq: self.seq,
13194 })
13195 .await?;
13196 self.state.intents_sent += 1;
13197 match target_point {
13198 Some(point) => self.state.push_log(format!(
13199 "Cast {ability_id} → ({:.1}, {:.1})",
13200 point.x, point.y
13201 )),
13202 None => self
13203 .state
13204 .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
13205 }
13206 Ok(())
13207 }
13208
13209 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
13210 self.seq += 1;
13211 self.session
13212 .submit_intent(Intent::UpsertRotationPreset {
13213 entity_id: self.state.entity_id,
13214 preset: preset.clone(),
13215 seq: self.seq,
13216 })
13217 .await?;
13218 self.state.intents_sent += 1;
13219 if let Some(existing) = self
13220 .state
13221 .rotation_presets
13222 .iter_mut()
13223 .find(|p| p.id == preset.id)
13224 {
13225 *existing = preset.clone();
13226 } else {
13227 self.state.rotation_presets.push(preset.clone());
13228 }
13229 for slot in &mut self.state.combat_slots {
13230 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
13231 slot.preset_label = Some(preset.label.clone());
13232 slot.rotation = preset.abilities.clone();
13233 }
13234 }
13235 self.state
13236 .push_log(format!("Saved rotation: {}", preset.label));
13237 Ok(())
13238 }
13239
13240 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
13241 self.seq += 1;
13242 self.session
13243 .submit_intent(Intent::DeleteRotationPreset {
13244 entity_id: self.state.entity_id,
13245 preset_id: preset_id.to_string(),
13246 seq: self.seq,
13247 })
13248 .await?;
13249 self.state.intents_sent += 1;
13250 self.state.rotation_presets.retain(|p| p.id != preset_id);
13251 for slot in &mut self.state.combat_slots {
13252 if slot.preset_id.as_deref() == Some(preset_id) {
13253 slot.preset_id = None;
13254 slot.preset_label = None;
13255 slot.rotation.clear();
13256 slot.rotation_index = 0;
13257 }
13258 }
13259 self.state
13260 .push_log(format!("Deleted rotation: {preset_id}"));
13261 Ok(())
13262 }
13263
13264 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
13265 if !self.state.is_alive() {
13266 anyhow::bail!("you are dead");
13267 }
13268 let enabled = !self
13269 .state
13270 .combat_slots
13271 .iter()
13272 .find(|s| s.slot_index == slot_index)
13273 .map(|s| s.auto_enabled)
13274 .unwrap_or(false);
13275 self.seq += 1;
13276 self.session
13277 .submit_intent(Intent::SetAutoAttack {
13278 entity_id: self.state.entity_id,
13279 slot_index,
13280 enabled,
13281 seq: self.seq,
13282 })
13283 .await?;
13284 if slot_index == 1 {
13285 self.state.auto_attack = enabled;
13286 }
13287 self.state.intents_sent += 1;
13288 self.state.push_log(format!(
13289 "T{slot_index} auto {}",
13290 if enabled { "ON" } else { "OFF" }
13291 ));
13292 Ok(())
13293 }
13294
13295 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
13296 if !self.state.connected {
13297 anyhow::bail!("not connected");
13298 }
13299 if !self.state.is_alive() {
13300 anyhow::bail!("you are dead");
13301 }
13302 let (px, py) = self.state.player_position();
13303 if self
13304 .state
13305 .ground_drops
13306 .iter()
13307 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
13308 {
13309 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
13310 }
13311 self.seq += 1;
13312 self.session
13313 .submit_intent(Intent::Pickup {
13314 entity_id: self.state.entity_id,
13315 drop_id: None,
13316 seq: self.seq,
13317 })
13318 .await?;
13319 self.state.intents_sent += 1;
13320 Ok(())
13321 }
13322
13323 pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
13324 self.toggle_auto_attack_slot(1).await
13325 }
13326
13327 pub async fn dodge(&mut self) -> anyhow::Result<()> {
13328 if !self.state.is_alive() {
13329 anyhow::bail!("you are dead");
13330 }
13331 self.seq += 1;
13332 self.session
13333 .submit_intent(Intent::Dodge {
13334 entity_id: self.state.entity_id,
13335 seq: self.seq,
13336 })
13337 .await?;
13338 self.state.intents_sent += 1;
13339 self.state.push_log("Dodge!");
13340 Ok(())
13341 }
13342
13343 pub async fn lunge(&mut self) -> anyhow::Result<()> {
13344 if !self.state.is_alive() {
13345 anyhow::bail!("you are dead");
13346 }
13347 let (forward, strafe) = self.last_move_axes();
13348 self.seq += 1;
13349 self.session
13350 .submit_intent(Intent::Lunge {
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("Lunge!");
13359 Ok(())
13360 }
13361
13362 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
13363 if !self.state.is_alive() {
13364 anyhow::bail!("you are dead");
13365 }
13366 self.seq += 1;
13367 self.session
13368 .submit_intent(Intent::DirectionalJump {
13369 entity_id: self.state.entity_id,
13370 forward,
13371 strafe,
13372 seq: self.seq,
13373 })
13374 .await?;
13375 self.state.intents_sent += 1;
13376 self.state.push_log("Jump!");
13377 Ok(())
13378 }
13379
13380 pub fn last_move_axes(&self) -> (f32, f32) {
13382 (self.last_move_forward, self.last_move_strafe)
13383 }
13384
13385 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
13386 if !self.state.is_alive() {
13387 anyhow::bail!("you are dead");
13388 }
13389 self.seq += 1;
13390 self.session
13391 .submit_intent(Intent::Block {
13392 entity_id: self.state.entity_id,
13393 enabled,
13394 seq: self.seq,
13395 })
13396 .await?;
13397 self.state.intents_sent += 1;
13398 if enabled {
13399 self.state.push_log("Blocking");
13400 }
13401 Ok(())
13402 }
13403
13404 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
13405 if !self.state.is_alive() {
13406 anyhow::bail!("you are dead");
13407 }
13408 self.seq += 1;
13409 self.session
13410 .submit_intent(Intent::EquipMainhand {
13411 entity_id: self.state.entity_id,
13412 template_id,
13413 instance_id: None,
13414 seq: self.seq,
13415 })
13416 .await?;
13417 self.state.intents_sent += 1;
13418 Ok(())
13419 }
13420
13421 pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
13423 let idx = self.state.equip_menu_index;
13424 let slots = equip_paperdoll_rows(&self.state);
13425 let Some(row) = slots.get(idx) else {
13426 return Ok(());
13427 };
13428 match row {
13429 EquipPaperdollRow::Body { slot, filled } => {
13430 if *filled {
13431 self.equip_worn(*slot, None).await
13432 } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
13433 self.equip_worn(*slot, Some(inst)).await
13434 } else {
13435 self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
13436 Ok(())
13437 }
13438 }
13439 EquipPaperdollRow::Mainhand { filled } => {
13440 if *filled {
13441 self.unequip_mainhand().await
13442 } else if let Some(tid) = first_inventory_weapon(&self.state) {
13443 self.equip_mainhand(Some(tid)).await
13444 } else {
13445 self.state.push_log("No weapon in inventory".to_string());
13446 Ok(())
13447 }
13448 }
13449 EquipPaperdollRow::Offhand { filled, locked } => {
13450 if *locked {
13451 self.state
13452 .push_log("Offhand locked — two-handed weapon equipped".to_string());
13453 Ok(())
13454 } else if *filled {
13455 self.unequip_offhand().await
13456 } else if let Some(tid) = first_inventory_offhand(&self.state) {
13457 self.equip_offhand(Some(tid)).await
13458 } else {
13459 self.state
13460 .push_log("No offhand item in inventory".to_string());
13461 Ok(())
13462 }
13463 }
13464 }
13465 }
13466
13467 pub async fn say(
13468 &mut self,
13469 channel: flatland_protocol::ChatChannel,
13470 text: &str,
13471 ) -> anyhow::Result<()> {
13472 self.say_to(channel, text, None).await
13473 }
13474
13475 pub async fn say_to(
13476 &mut self,
13477 channel: flatland_protocol::ChatChannel,
13478 text: &str,
13479 to_entity: Option<EntityId>,
13480 ) -> anyhow::Result<()> {
13481 self.seq += 1;
13482 self.session
13483 .submit_intent(Intent::Say {
13484 entity_id: self.state.entity_id,
13485 channel,
13486 text: text.to_string(),
13487 to_entity,
13488 seq: self.seq,
13489 })
13490 .await?;
13491 self.state.intents_sent += 1;
13492 Ok(())
13493 }
13494
13495 pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
13496 let Some(peer) = self.state.player_verbs.target_entity else {
13497 return Ok(());
13498 };
13499 let label = self.state.player_verbs.target_label.clone();
13500 let choice = crate::social::PlayerVerbState::options()
13501 .get(self.state.player_verbs.index)
13502 .copied()
13503 .unwrap_or("Whisper");
13504 self.state.player_verbs.close();
13505 match choice {
13506 "Trade" => {
13507 self.seq += 1;
13510 self.session
13511 .submit_intent(Intent::TradeRequest {
13512 entity_id: self.state.entity_id,
13513 peer_entity_id: peer,
13514 seq: self.seq,
13515 })
13516 .await?;
13517 self.state.intents_sent += 1;
13518 self.state
13519 .social_chat
13520 .push_system(format!("Trade request sent to {label} — waiting for accept"));
13521 }
13522 "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
13523 _ => self.state.social_chat.focus_nearby(),
13524 }
13525 Ok(())
13526 }
13527
13528 pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
13529 let Some(pending) = self.state.social_chat.pending_trade.take() else {
13530 return Ok(());
13531 };
13532 self.seq += 1;
13533 self.session
13534 .submit_intent(Intent::TradeRespond {
13535 entity_id: self.state.entity_id,
13536 peer_entity_id: pending.from_entity,
13537 accept,
13538 seq: self.seq,
13539 })
13540 .await?;
13541 self.state.intents_sent += 1;
13542 if accept {
13543 self.state
13544 .social_chat
13545 .push_system(format!("Accepted trade with {}", pending.from_name));
13546 } else {
13547 self.state
13548 .social_chat
13549 .push_system(format!("Declined trade with {}", pending.from_name));
13550 }
13551 Ok(())
13552 }
13553
13554 pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
13555 let text = self.state.social_chat.buffer.trim().to_string();
13556 if text.is_empty() {
13557 return Ok(());
13558 }
13559 self.state.social_chat.buffer.clear();
13560 if crate::social::is_chat_slash_line(&text) {
13561 match crate::social::parse_chat_slash(&text) {
13562 Some(cmd) => return self.apply_chat_slash(cmd).await,
13563 None => {
13564 self.state.social_chat.push_system(format!(
13565 "Unknown command — {}",
13566 crate::social::chat_slash_help_text()
13567 ));
13568 return Ok(());
13569 }
13570 }
13571 }
13572 let thread = self.state.social_chat.thread;
13573 let channel = thread.channel();
13574 let to = thread.to_entity();
13575 if let Some(peer) = to {
13576 let label = self.state.social_chat.peer_label.clone();
13577 self.state
13578 .social_chat
13579 .remember_whisper_peer(peer, &label, channel);
13580 }
13581 self.say_to(channel, &text, to).await
13582 }
13583
13584 async fn apply_chat_slash(
13585 &mut self,
13586 cmd: crate::social::ChatSlashCommand,
13587 ) -> anyhow::Result<()> {
13588 use crate::social::{chat_slash_help_text, ChatSlashCommand};
13589 match cmd {
13590 ChatSlashCommand::Help => {
13591 self.state
13592 .social_chat
13593 .push_system(chat_slash_help_text().to_string());
13594 Ok(())
13595 }
13596 ChatSlashCommand::Nearby { message } => {
13597 self.state.social_chat.focus_nearby();
13598 self.state
13599 .social_chat
13600 .push_system("Nearby speech — everyone close can hear");
13601 if let Some(msg) = message {
13602 self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
13603 .await
13604 } else {
13605 Ok(())
13606 }
13607 }
13608 ChatSlashCommand::Reply { message } => {
13609 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
13610 self.state.social_chat.push_system(
13611 "No one to reply to — wait for a whisper, or /whisper Name",
13612 );
13613 return Ok(());
13614 };
13615 let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
13616 self.state
13617 .social_chat
13618 .set_whisper_thread(peer.entity_id, &peer.label, stone);
13619 self.state.social_chat.push_system(format!(
13620 "Replying to {} — type and Enter · /nearby",
13621 peer.label
13622 ));
13623 if let Some(msg) = message {
13624 self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
13625 } else {
13626 Ok(())
13627 }
13628 }
13629 ChatSlashCommand::Whisper { name, message } => {
13630 let (peer_id, label, stone) = if let Some(name) = name {
13631 match self.resolve_whisper_target(&name) {
13632 Ok(t) => t,
13633 Err(err) => {
13634 self.state.social_chat.push_system(err);
13635 return Ok(());
13636 }
13637 }
13638 } else {
13639 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
13640 self.state.social_chat.push_system(
13641 "Usage: /whisper Name [message] · or /reply after someone whispers you",
13642 );
13643 return Ok(());
13644 };
13645 (
13646 peer.entity_id,
13647 peer.label,
13648 peer.channel == flatland_protocol::ChatChannel::WhisperStone,
13649 )
13650 };
13651 self.state
13652 .social_chat
13653 .set_whisper_thread(peer_id, &label, stone);
13654 let channel = if stone {
13655 flatland_protocol::ChatChannel::WhisperStone
13656 } else {
13657 flatland_protocol::ChatChannel::Whisper
13658 };
13659 if let Some(msg) = message {
13660 self.state.social_chat.push_system(format!(
13661 "Whisper → {label}"
13662 ));
13663 self.say_to(channel, &msg, Some(peer_id)).await
13664 } else {
13665 self.state.social_chat.push_system(format!(
13666 "Whispering {label} — type and Enter · Esc / /nearby cancels"
13667 ));
13668 Ok(())
13669 }
13670 }
13671 }
13672 }
13673
13674 fn resolve_whisper_target(
13676 &self,
13677 name: &str,
13678 ) -> Result<(EntityId, String, bool), String> {
13679 let needle = name.trim().to_ascii_lowercase();
13680 if needle.is_empty() {
13681 return Err("Usage: /whisper Name [message]".into());
13682 }
13683 let mut candidates: Vec<(EntityId, String)> = self
13684 .state
13685 .entities
13686 .iter()
13687 .filter(|e| e.id != self.state.entity_id)
13688 .filter(|e| !e.label.trim().is_empty())
13689 .filter(|e| e.vitals.is_some())
13690 .filter(|e| {
13691 !self
13692 .state
13693 .npcs
13694 .iter()
13695 .any(|n| n.id == e.id.to_string())
13696 })
13697 .filter(|e| {
13698 !self
13699 .state
13700 .hired_workers
13701 .iter()
13702 .any(|w| w.entity_id == e.id)
13703 })
13704 .map(|e| (e.id, e.label.clone()))
13705 .collect();
13706
13707 if let Some(last) = &self.state.social_chat.last_whisper_peer {
13709 if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
13710 candidates.push((last.entity_id, last.label.clone()));
13711 }
13712 }
13713
13714 let exact: Vec<_> = candidates
13715 .iter()
13716 .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
13717 .cloned()
13718 .collect();
13719 let pool = if exact.len() == 1 {
13720 exact
13721 } else if exact.len() > 1 {
13722 return Err(format!(
13723 "Several players named '{name}' nearby — move closer and try again"
13724 ));
13725 } else {
13726 let starts: Vec<_> = candidates
13727 .iter()
13728 .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
13729 .cloned()
13730 .collect();
13731 if starts.len() == 1 {
13732 starts
13733 } else if starts.len() > 1 {
13734 let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
13735 return Err(format!(
13736 "Ambiguous name '{name}' — matches: {}",
13737 names.join(", ")
13738 ));
13739 } else {
13740 let contains: Vec<_> = candidates
13741 .iter()
13742 .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
13743 .cloned()
13744 .collect();
13745 if contains.len() == 1 {
13746 contains
13747 } else if contains.is_empty() {
13748 return Err(format!(
13749 "No player matching '{name}' in range — get closer or check the spelling"
13750 ));
13751 } else {
13752 let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
13753 return Err(format!(
13754 "Ambiguous name '{name}' — matches: {}",
13755 names.join(", ")
13756 ));
13757 }
13758 }
13759 };
13760
13761 let (id, label) = pool.into_iter().next().unwrap();
13762 let stone = self
13763 .state
13764 .social_chat
13765 .last_whisper_peer
13766 .as_ref()
13767 .is_some_and(|p| p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone);
13768 Ok((id, label, stone))
13769 }
13770
13771 pub async fn trade_present_selected(
13772 &mut self,
13773 item_instance_id: uuid::Uuid,
13774 ) -> anyhow::Result<()> {
13775 self.trade_present_quantity(item_instance_id, None).await
13776 }
13777
13778 pub async fn trade_present_quantity(
13779 &mut self,
13780 item_instance_id: uuid::Uuid,
13781 quantity: Option<u32>,
13782 ) -> anyhow::Result<()> {
13783 self.seq += 1;
13784 self.session
13785 .submit_intent(Intent::TradePresent {
13786 entity_id: self.state.entity_id,
13787 item_instance_id,
13788 quantity,
13789 seq: self.seq,
13790 })
13791 .await?;
13792 self.state.intents_sent += 1;
13793 self.state.trade_ui.qty_entry = None;
13794 self.state.trade_ui.picking_inventory = false;
13795 Ok(())
13796 }
13797
13798 pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
13800 if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
13801 let qty = self.state.trade_ui.present_quantity();
13802 return self
13803 .trade_present_quantity(entry.item_instance_id, qty)
13804 .await;
13805 }
13806 if !self.state.trade_ui.picking_inventory {
13807 return Ok(());
13808 }
13809 let Some(stack) = self
13810 .state
13811 .inventory_stacks
13812 .get(self.state.trade_ui.inventory_index)
13813 .cloned()
13814 else {
13815 return Ok(());
13816 };
13817 let Some(id) = stack.item_instance_id else {
13818 return Ok(());
13819 };
13820 let label = stack
13821 .display_name
13822 .clone()
13823 .unwrap_or_else(|| stack.template_id.clone());
13824 if stack.quantity <= 1 {
13825 self.trade_present_quantity(id, Some(1)).await
13826 } else {
13827 self.state
13828 .trade_ui
13829 .begin_qty_entry(id, label, stack.quantity);
13830 Ok(())
13831 }
13832 }
13833
13834 pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
13835 self.seq += 1;
13836 self.session
13837 .submit_intent(Intent::TradeSetReady {
13838 entity_id: self.state.entity_id,
13839 ready,
13840 seq: self.seq,
13841 })
13842 .await?;
13843 self.state.intents_sent += 1;
13844 Ok(())
13845 }
13846
13847 pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
13848 self.seq += 1;
13849 self.session
13850 .submit_intent(Intent::TradeCancel {
13851 entity_id: self.state.entity_id,
13852 seq: self.seq,
13853 })
13854 .await?;
13855 self.state.intents_sent += 1;
13856 self.state.trade_ui.close();
13857 Ok(())
13858 }
13859
13860 pub async fn destroy_whisper_stone(
13861 &mut self,
13862 item_instance_id: uuid::Uuid,
13863 ) -> anyhow::Result<()> {
13864 self.seq += 1;
13865 self.session
13866 .submit_intent(Intent::DestroyWhisperStone {
13867 entity_id: self.state.entity_id,
13868 item_instance_id,
13869 seq: self.seq,
13870 })
13871 .await?;
13872 self.state.intents_sent += 1;
13873 Ok(())
13874 }
13875
13876 pub async fn stop(&mut self) -> anyhow::Result<()> {
13877 self.seq += 1;
13878 self.session
13879 .submit_intent(Intent::Stop {
13880 entity_id: self.state.entity_id,
13881 seq: self.seq,
13882 })
13883 .await?;
13884 self.state.intents_sent += 1;
13885 Ok(())
13886 }
13887
13888 pub fn disconnect(&self) {
13889 self.session.disconnect();
13890 }
13891}
13892
13893fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
13894 let dx = ax - bx;
13895 let dy = ay - by;
13896 (dx * dx + dy * dy).sqrt()
13897}
13898
13899#[cfg(test)]
13900mod tests {
13901 use std::collections::BTreeMap;
13902
13903 use super::*;
13904 use flatland_protocol::{
13905 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
13906 };
13907
13908 fn sample_state() -> GameState {
13909 let mut state = GameState {
13910 session_id: 1,
13911 entity_id: 1,
13912 character_id: None,
13913 tick: 0,
13914 chunk_rev: 0,
13915 content_rev: 0,
13916 publish_rev: 0,
13917 entities: vec![EntityState {
13918 id: 1,
13919 label: "You".into(),
13920 transform: Transform {
13921 position: WorldCoord::surface(128.0, 128.0),
13922 yaw: 0.0,
13923 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
13924 },
13925 vitals: None,
13926 attributes: None,
13927 skills: None,
13928 inside_building: None,
13929 tile_id: None,
13930 paperdoll_ref: None,
13931 presentation_state: None,
13932 sprite_mode: None,
13933 progression_xp: None,
13934 combat_cues: vec![],
13935 }],
13936 player: None,
13937 resource_nodes: vec![ResourceNodeView {
13938 id: "oak-1".into(),
13939 label: "Oak".into(),
13940 x: 130.0,
13941 y: 128.0,
13942 z: 0.0,
13943 item_template: "oak_log".into(),
13944 state: ResourceNodeState::Available,
13945 blocking: true,
13946 blocking_radius_m: 0.8,
13947 tile_id: None,
13948 yaw: 0.0,
13949 pitch: 0.0,
13950 roll: 0.0,
13951 draw_scale: 1.0,
13952 sprite_mode: None,
13953 growth_progress: None,
13954 presentation_state: None,
13955 channel_start_tick: None,
13956 channel_end_tick: None,
13957 harvest_drop_templates: vec![],
13958 }],
13959 ground_drops: vec![],
13960 placed_containers: vec![],
13961 buildings: vec![BuildingView {
13962 id: "broker-hut".into(),
13963 label: "Broker".into(),
13964 x: 148.0,
13965 y: 118.0,
13966 width_m: 8.0,
13967 depth_m: 6.0,
13968 interior_blueprint: Some("broker_hut".into()),
13969 tags: vec![],
13970 market_boundary_zone_ids: vec![],
13971 market_max_volume: None,
13972 wall_set: None,
13973 roof_set: None,
13974 }],
13975 doors: vec![flatland_protocol::DoorView {
13976 id: "door-1".into(),
13977 building_id: "broker-hut".into(),
13978 x: 148.0,
13979 y: 118.0,
13980 open: false,
13981 portal: Some("front".into()),
13982 }],
13983 interior_map: None,
13984 npcs: vec![],
13985 blueprints: vec![],
13986 world_x0: 0.0,
13987 world_y0: 0.0,
13988 world_width_m: 256.0,
13989 world_height_m: 256.0,
13990 terrain_zones: Vec::new(),
13991 z_platforms: Vec::new(),
13992 z_transitions: Vec::new(),
13993 z_bands_outdoor_backup: None,
13994 world_clock: flatland_protocol::WorldClock::default(),
13995 inventory: std::collections::HashMap::new(),
13996 inventory_hints: std::collections::HashMap::new(),
13997 logs: VecDeque::new(),
13998 intents_sent: 0,
13999 ticks_received: 0,
14000 connected: true,
14001 disconnect_reason: None,
14002 show_stats: false,
14003 hud_log_hidden: false,
14004 show_equip_menu: false,
14005 equip_menu_index: 0,
14006 show_craft_menu: false,
14007 craft_menu_index: 0,
14008 craft_batch_quantity: 1,
14009 show_shop_menu: false,
14010 shop_catalog: None,
14011 bank_panel: None,
14012 bank_menu_index: 0,
14013 bank_ui_mode: BankUiMode::Menu,
14014 storage_panel: None,
14015 market_panel: None,
14016 market_menu_index: 0,
14017 market_filter: String::new(),
14018 market_filter_focused: false,
14019 market_category_filter: None,
14020 market_buy_confirm: None,
14021 market_ui_mode: MarketUiMode::Browse,
14022 storage_menu_index: 0,
14023 storage_ui_mode: StorageUiMode::Menu,
14024 shop_tab: ShopTab::default(),
14025 shop_menu_index: 0,
14026 shop_quantity: 1,
14027 shop_trade_log: VecDeque::new(),
14028 show_npc_verb_menu: false,
14029 npc_verb_target: None,
14030 npc_verb_index: 0,
14031 player_verbs: crate::social::PlayerVerbState::default(),
14032 social_chat: crate::social::SocialChatState::default(),
14033 trade_ui: crate::social::TradeUiState::default(),
14034 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
14035 show_npc_chat: false,
14036 npc_chat: None,
14037 show_inventory_menu: false,
14038 inventory_menu_index: 0,
14039 inventory_tab: InventoryTab::OnPerson,
14040 inventory_filter: String::new(),
14041 inventory_filter_focused: false,
14042 show_move_picker: false,
14043 show_rename_prompt: false,
14044 show_worker_rename: false,
14045 rename_buffer: String::new(),
14046 move_picker_index: 0,
14047 move_picker: None,
14048 show_grant_picker: false,
14049 grant_picker_index: 0,
14050 grant_picker: None,
14051 show_destroy_picker: false,
14052 destroy_confirm_pending: false,
14053 destroy_picker: None,
14054 combat_target: None,
14055 combat_target_label: None,
14056 ground_target: None,
14057 combat_fx: Vec::new(),
14058 property_zones: Vec::new(),
14059 tax_zones: Vec::new(),
14060 growth_zones: Vec::new(),
14061 biome_zones: Vec::new(),
14062 property_plots: Vec::new(),
14063 property_plot_settings: None,
14064 claim_mode: None,
14065 relocate_mode: None,
14066 sell_plot_confirm: None,
14067 sell_plot_armed_at: None,
14068 show_plant_menu: false,
14069 plant_menu_index: 0,
14070 show_farm_access: false,
14071 farm_access_name_draft: String::new(),
14072 farm_access_discount_bps: 0,
14073 farm_access_index: 0,
14074 plant_quantity: 1,
14075 in_combat: false,
14076 auto_attack: true,
14077 combat_has_los: false,
14078 attack_cd_ticks: 0,
14079 gcd_ticks: 0,
14080 weapon_ability_id: "unarmed".into(),
14081 mainhand_template_id: None,
14082 mainhand_label: None,
14083 offhand_template_id: None,
14084 offhand_label: None,
14085 mainhand_hand_slots: 1,
14086 defense: None,
14087 worn: BTreeMap::new(),
14088 carry_mass: 0.0,
14089 carry_mass_max: 0.0,
14090 encumbrance: flatland_protocol::EncumbranceState::Light,
14091 inventory_stacks: Vec::new(),
14092 keychain_stacks: Vec::new(),
14093 whisper_pouch_stacks: Vec::new(),
14094 combat_target_detail: None,
14095 statuses: Vec::new(),
14096 cast_progress: None,
14097 timed_channel: None,
14098 ability_cooldowns: Vec::new(),
14099 blocking_active: false,
14100 max_target_slots: 1,
14101 combat_slots: Vec::new(),
14102 rotation_presets: Vec::new(),
14103 known_abilities: Vec::new(),
14104 ability_meta: std::collections::HashMap::new(),
14105 ability_mastery: std::collections::HashMap::new(),
14106 hotbar: vec![None; 9],
14107 max_abilities_per_rotation: 0,
14108 show_loadout_menu: false,
14109 show_keychain_menu: false,
14110 keychain_menu_index: 0,
14111 show_rotation_editor: false,
14112 loadout_menu_index: 0,
14113 loadout_hotbar_slot: 1,
14114 loadout_ability_index: 0,
14115 loadout_focus_presets: false,
14116 rotation_editor: RotationEditorState::default(),
14117 harvest_in_progress: false,
14118 harvest_started_at: None,
14119 pending_craft_ack: None,
14120 pending_worker_job_ack: None,
14121 attending_worker_instance_id: None,
14122 quest_log: Vec::new(),
14123 interactables: Vec::new(),
14124 ledger: None,
14125 career: None,
14126 character_sheet_tab: CharacterSheetTab::Character,
14127 ledger_period: LedgerPeriod::Day,
14128 show_quest_offer: false,
14129 pending_quest_offer: None,
14130 show_quest_menu: false,
14131 quest_menu_index: 0,
14132 quest_withdraw_confirm: false,
14133 hired_workers: Vec::new(),
14134 show_workers_menu: false,
14135 workers_menu_index: 0,
14136 workers_menu_compact: false,
14137 worker_step_display: BTreeMap::new(),
14138 worker_error_display: BTreeMap::new(),
14139 show_worker_give_picker: false,
14140 worker_give_picker_index: 0,
14141 worker_give_picker: None,
14142 show_worker_give_target_picker: false,
14143 worker_give_target_picker_index: 0,
14144 worker_give_target_picker: None,
14145 show_worker_take_picker: false,
14146 worker_take_picker_index: 0,
14147 worker_take_picker: None,
14148 show_worker_teach_picker: false,
14149 worker_teach_picker_index: 0,
14150 worker_teach_picker: None,
14151 worker_route_editor: None,
14152 progression_curve: None,
14153 };
14154 state.player = state.entities.first().cloned();
14155 state
14156 }
14157
14158 #[test]
14159 fn whisper_cancels_when_peer_walks_out_of_range() {
14160 let mut state = sample_state();
14161 state.player = state.entities.first().cloned();
14162 let mut peer = state.entities[0].clone();
14163 peer.id = 2;
14164 peer.label = "Ada".into();
14165 peer.transform.position = WorldCoord::surface(129.0, 128.0); state.entities.push(peer.clone());
14167 state.social_chat.focus_whisper(2, "Ada");
14168 state.refresh_whisper_range();
14169 assert!(matches!(
14170 state.social_chat.thread,
14171 crate::social::ChatThreadKind::Whisper { peer: 2 }
14172 ));
14173
14174 peer.transform.position = WorldCoord::surface(132.0, 128.0); state.entities[1] = peer;
14176 state.refresh_whisper_range();
14177 assert_eq!(
14178 state.social_chat.thread,
14179 crate::social::ChatThreadKind::Nearby
14180 );
14181 assert!(!state.social_chat.input_focused);
14182 }
14183
14184 #[test]
14185 fn probe_use_world_hired_worker_manage() {
14186 let mut state = sample_state();
14187 state.hired_workers.push(flatland_protocol::HiredWorkerView {
14188 instance_id: "worker-1".into(),
14189 entity_id: 42,
14190 def_id: "worker_laborer".into(),
14191 label: "Sam".into(),
14192 x: 129.0,
14193 y: 128.0,
14194 z: 0.0,
14195 mode: flatland_protocol::WorkerModeView::JobLoop,
14196 state: flatland_protocol::WorkerStateView::Working,
14197 step_label: "cultivate".into(),
14198 vitals: flatland_protocol::WorkerVitalsSummary {
14199 health_pct: 100.0,
14200 stamina_pct: 100.0,
14201 },
14202 carry_pct: 0.0,
14203 last_error: None,
14204 wage_copper_per_interval: 1,
14205 effective_wage_copper: 1,
14206 wage_meters_walked: 0.0,
14207 lodging_container_id: None,
14208 route: None,
14209 route_stop_index: None,
14210 known_blueprint_ids: Vec::new(),
14211 level: 1,
14212 worker_xp: 0.0,
14213 inventory: Vec::new(),
14214 });
14215 let probe = state.probe_use_world();
14216 let primary = probe.primary.expect("primary");
14217 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
14218 assert_eq!(primary.id, "worker-1");
14219 assert!(primary.hint_line().contains("Manage"));
14220 assert!(primary.hint_line().contains("Sam"));
14221 assert_eq!(
14222 state.nearest_interact_target().as_deref(),
14223 Some("worker-1")
14224 );
14225 }
14226
14227 #[test]
14228 fn market_clerk_verb_options_include_market() {
14229 let mut state = sample_state();
14230 state.npcs.push(flatland_protocol::NpcView {
14231 id: "mira_market".into(),
14232 label: "Mira".into(),
14233 role: "market_clerk".into(),
14234 x: 129.0,
14235 y: 128.0,
14236 building_id: Some("town_market".into()),
14237 entity_id: None,
14238 life_state: None,
14239 hp_pct: None,
14240 can_trade: false,
14241 tile_id: None,
14242 behavior_state: None,
14243 presentation_state: None,
14244 sprite_mode: None,
14245 paperdoll_ref: None,
14246 });
14247 state.npc_verb_target = Some("mira_market".into());
14248 assert_eq!(state.npc_verb_options(), vec!["Market", "Talk"]);
14249 }
14250
14251 #[test]
14252 fn market_list_excludes_currency_stacks() {
14253 let mut state = sample_state();
14254 state.inventory_stacks = vec![
14255 flatland_protocol::ItemStack {
14256 template_id: "copper_coin".into(),
14257 quantity: 50,
14258 item_instance_id: Some(uuid::Uuid::from_u128(10)),
14259 display_name: Some("Copper Coin".into()),
14260 ..Default::default()
14261 },
14262 flatland_protocol::ItemStack {
14263 template_id: "oak_log".into(),
14264 quantity: 2,
14265 item_instance_id: Some(uuid::Uuid::from_u128(11)),
14266 display_name: Some("Oak Log".into()),
14267 ..Default::default()
14268 },
14269 flatland_protocol::ItemStack {
14270 template_id: "whisper_stone".into(),
14271 quantity: 1,
14272 item_instance_id: Some(uuid::Uuid::from_u128(12)),
14273 display_name: Some("Whisper Stone".into()),
14274 category: Some("quest".into()),
14275 listable: Some(false),
14276 ..Default::default()
14277 },
14278 ];
14279 let opts = state.market_list_item_options(&MarketListSourceKind::Person);
14280 assert_eq!(opts.len(), 1);
14281 assert!(opts[0].label.contains("Oak"));
14282 }
14283
14284 #[test]
14285 fn market_browse_filters_by_category_and_search() {
14286 let mut state = sample_state();
14287 state.market_panel = Some(flatland_protocol::MarketPanel {
14288 npc_id: "mira_market".into(),
14289 npc_label: "Mira".into(),
14290 building_id: "town_market".into(),
14291 building_label: "Town Market".into(),
14292 used_volume: 0.0,
14293 max_volume: 100.0,
14294 listings: vec![
14295 flatland_protocol::MarketListingView {
14296 listing_id: uuid::Uuid::from_u128(1),
14297 seller_character_id: uuid::Uuid::from_u128(2),
14298 seller_label: "Ada".into(),
14299 hall_building_id: "town_market".into(),
14300 hall_label: "Town Market".into(),
14301 template_id: "oak_log".into(),
14302 display_name: "Oak Log".into(),
14303 category: "resource".into(),
14304 quantity: 3,
14305 unit_price_copper: 10,
14306 line_total_copper: 30,
14307 mine: false,
14308 },
14309 flatland_protocol::MarketListingView {
14310 listing_id: uuid::Uuid::from_u128(3),
14311 seller_character_id: uuid::Uuid::from_u128(2),
14312 seller_label: "Ada".into(),
14313 hall_building_id: "town_market".into(),
14314 hall_label: "Town Market".into(),
14315 template_id: "short_sword".into(),
14316 display_name: "Short Sword".into(),
14317 category: "weapon".into(),
14318 quantity: 1,
14319 unit_price_copper: 100,
14320 line_total_copper: 100,
14321 mine: false,
14322 },
14323 ],
14324 tax_bps: 0,
14325 tax_flat_copper: 0,
14326 list_vaults: vec![],
14327 });
14328 assert_eq!(state.market_filtered_listing_indices().len(), 2);
14329 state.market_category_filter = Some("Weapons");
14330 let weapons = state.market_filtered_listing_indices();
14331 assert_eq!(weapons.len(), 1);
14332 assert_eq!(
14333 state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
14334 "Short Sword"
14335 );
14336 state.market_category_filter = None;
14337 state.market_filter = "oak".into();
14338 let oak = state.market_filtered_listing_indices();
14339 assert_eq!(oak.len(), 1);
14340 assert_eq!(
14341 state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
14342 "Oak Log"
14343 );
14344 }
14345
14346 #[test]
14347 fn market_list_source_includes_person_and_vaults() {
14348 let mut state = sample_state();
14349 let item_id = uuid::Uuid::from_u128(1);
14350 state.inventory_stacks = vec![flatland_protocol::ItemStack {
14351 template_id: "oak_log".into(),
14352 quantity: 2,
14353 item_instance_id: Some(item_id),
14354 display_name: Some("Oak Log".into()),
14355 ..Default::default()
14356 }];
14357 state.market_panel = Some(flatland_protocol::MarketPanel {
14358 npc_id: "mira_market".into(),
14359 npc_label: "Mira".into(),
14360 building_id: "town_market".into(),
14361 building_label: "Town Market".into(),
14362 used_volume: 0.0,
14363 max_volume: 100.0,
14364 listings: vec![],
14365 tax_bps: 0,
14366 tax_flat_copper: 0,
14367 list_vaults: vec![flatland_protocol::MarketListVault {
14368 building_id: "town_storage".into(),
14369 building_label: "Town Storage".into(),
14370 contents: vec![flatland_protocol::ItemStack {
14371 template_id: "lumber".into(),
14372 quantity: 1,
14373 item_instance_id: Some(uuid::Uuid::from_u128(2)),
14374 display_name: Some("Lumber".into()),
14375 ..Default::default()
14376 }],
14377 }],
14378 });
14379 let sources = state.market_list_source_options();
14380 assert_eq!(sources.len(), 2);
14381 assert!(matches!(sources[0].0, MarketListSourceKind::Person));
14382 assert!(matches!(
14383 sources[1].0,
14384 MarketListSourceKind::TownStorage { .. }
14385 ));
14386 assert!(sources[1].1.contains("Town Storage"));
14387 }
14388
14389 #[test]
14390 fn probe_use_world_npc_beats_nearby_loot() {
14391 let mut state = sample_state();
14392 state.npcs.push(flatland_protocol::NpcView {
14393 id: "ada".into(),
14394 label: "Ada".into(),
14395 role: "broker".into(),
14396 x: 129.0,
14397 y: 128.0,
14398 building_id: None,
14399 entity_id: None,
14400 life_state: None,
14401 hp_pct: None,
14402 can_trade: true,
14403 tile_id: None,
14404 behavior_state: None,
14405 presentation_state: None,
14406 sprite_mode: None,
14407 paperdoll_ref: None,
14408 });
14409 state.ground_drops.push(flatland_protocol::GroundDropView {
14410 id: "d1".into(),
14411 template_id: "lumber".into(),
14412 quantity: 1,
14413 x: 128.5,
14414 y: 128.0,
14415 z: 0.0,
14416 tile_id: None,
14417 display_name: None,
14418 yaw: 0.0,
14419 pitch: 0.0,
14420 roll: 0.0,
14421 draw_scale: 1.0,
14422 });
14423 let probe = state.probe_use_world();
14424 let primary = probe.primary.expect("primary");
14425 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
14426 assert_eq!(primary.id, "ada");
14427 }
14428
14429 #[test]
14430 fn probe_use_world_harvest_when_in_range() {
14431 let state = sample_state(); let probe = state.probe_use_world();
14433 assert!(
14434 probe.primary.is_none(),
14435 "oak is 2m away, out of harvest range"
14436 );
14437 assert!(probe
14438 .candidates
14439 .iter()
14440 .any(|c| c.kind == crate::UseWorldKind::Harvest));
14441
14442 let mut state = sample_state();
14443 state.resource_nodes[0].x = 129.0;
14444 let probe = state.probe_use_world();
14445 let primary = probe.primary.expect("primary");
14446 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
14447 }
14448
14449 #[test]
14450 fn probe_use_world_door_uses_building_label() {
14451 let mut state = sample_state();
14452 state.doors[0].x = 129.0;
14453 state.doors[0].y = 128.0;
14454 let probe = state.probe_use_world();
14455 let primary = probe.primary.expect("primary");
14456 assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
14457 assert_eq!(primary.label, "Broker");
14458 assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
14459 }
14460
14461 #[test]
14462 fn empty_entity_tick_preserves_welcome_snapshot() {
14463 let mut state = sample_state();
14464 state.inventory.insert("carrot".into(), 3);
14465 let delta = TickDelta {
14466 tick: 1,
14467 entities: vec![],
14468 resource_nodes: vec![],
14469 ground_drops: vec![],
14470 placed_containers: vec![],
14471 buildings: vec![],
14472 doors: vec![],
14473 interior_map: None,
14474 npcs: vec![],
14475 inventory: vec![],
14476 blueprints: vec![],
14477 world_clock: flatland_protocol::WorldClock::default(),
14478 combat: None,
14479 quest_log: vec![],
14480 hired_workers: Vec::new(),
14481 interactables: vec![],
14482 ledger: None,
14483 career: None,
14484 combat_fx: Vec::new(),
14485 property_plots: Vec::new(),
14486 terrain_overlays: Vec::new(),
14487 };
14488
14489 state.apply_tick_fields(&delta, 1);
14490
14491 assert_eq!(state.entities.len(), 1);
14492 assert!(state.player.is_some());
14493 assert_eq!(state.inventory.get("carrot"), Some(&3));
14494 assert_eq!(state.resource_nodes.len(), 1);
14495 }
14496
14497 #[test]
14498 fn tick_preserves_world_layers_when_delta_omits_them() {
14499 let mut state = sample_state();
14500 let delta = TickDelta {
14501 tick: 1,
14502 entities: state.entities.clone(),
14503 resource_nodes: vec![],
14504 ground_drops: vec![],
14505 placed_containers: vec![],
14506 buildings: vec![],
14507 doors: vec![],
14508 interior_map: None,
14509 npcs: vec![],
14510 inventory: vec![],
14511 blueprints: vec![],
14512 world_clock: flatland_protocol::WorldClock::default(),
14513 combat: None,
14514 quest_log: vec![],
14515 hired_workers: Vec::new(),
14516 interactables: vec![],
14517 ledger: None,
14518 career: None,
14519 combat_fx: Vec::new(),
14520 property_plots: Vec::new(),
14521 terrain_overlays: Vec::new(),
14522 };
14523
14524 state.apply_tick_fields(&delta, 1);
14525
14526 assert_eq!(state.resource_nodes.len(), 1);
14527 assert_eq!(state.buildings.len(), 1);
14528 assert_eq!(state.doors.len(), 1);
14529 }
14530
14531 #[test]
14532 fn tick_updates_resource_nodes_when_server_sends_them() {
14533 let mut state = sample_state();
14534 let delta = TickDelta {
14535 tick: 1,
14536 entities: state.entities.clone(),
14537 resource_nodes: vec![ResourceNodeView {
14538 id: "oak-1".into(),
14539 label: "Oak".into(),
14540 x: 130.0,
14541 y: 128.0,
14542 z: 0.0,
14543 item_template: "oak_log".into(),
14544 state: ResourceNodeState::Cooldown,
14545 blocking: true,
14546 blocking_radius_m: 0.8,
14547 tile_id: None,
14548 yaw: 0.0,
14549 pitch: 0.0,
14550 roll: 0.0,
14551 draw_scale: 1.0,
14552 sprite_mode: None,
14553 growth_progress: None,
14554 presentation_state: None,
14555 channel_start_tick: None,
14556 channel_end_tick: None,
14557 harvest_drop_templates: vec![],
14558 }],
14559 buildings: vec![],
14560 doors: vec![],
14561 interior_map: None,
14562 npcs: vec![],
14563 inventory: vec![],
14564 blueprints: vec![],
14565 world_clock: flatland_protocol::WorldClock::default(),
14566 ground_drops: vec![],
14567 placed_containers: vec![],
14568 combat: None,
14569 quest_log: vec![],
14570 hired_workers: Vec::new(),
14571 interactables: vec![],
14572 ledger: None,
14573 career: None,
14574 combat_fx: Vec::new(),
14575 property_plots: Vec::new(),
14576 terrain_overlays: Vec::new(),
14577 };
14578
14579 state.apply_tick_fields(&delta, 1);
14580
14581 assert!(matches!(
14582 state.resource_nodes[0].state,
14583 ResourceNodeState::Cooldown
14584 ));
14585 }
14586
14587 #[test]
14588 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
14589 let mut state = GameState {
14590 session_id: 1,
14591 entity_id: 1,
14592 character_id: None,
14593 tick: 0,
14594 chunk_rev: 0,
14595 content_rev: 0,
14596 publish_rev: 0,
14597 entities: vec![EntityState {
14598 id: 1,
14599 label: "You".into(),
14600 transform: Transform {
14601 position: WorldCoord::surface(4.5, 2.0),
14602 yaw: 0.0,
14603 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
14604 },
14605 vitals: None,
14606 attributes: None,
14607 skills: None,
14608 inside_building: Some("broker_hut".into()),
14609 tile_id: None,
14610 paperdoll_ref: None,
14611 presentation_state: None,
14612 sprite_mode: None,
14613 progression_xp: None,
14614 combat_cues: vec![],
14615 }],
14616 player: None,
14617 resource_nodes: vec![],
14618 ground_drops: vec![],
14619 placed_containers: vec![],
14620 buildings: vec![BuildingView {
14621 id: "broker_hut".into(),
14622 label: "Broker".into(),
14623 x: 158.0,
14624 y: 124.0,
14625 width_m: 8.0,
14626 depth_m: 6.0,
14627 interior_blueprint: Some("broker_hut".into()),
14628 tags: vec![],
14629 market_boundary_zone_ids: vec![],
14630 market_max_volume: None,
14631 wall_set: None,
14632 roof_set: None,
14633 }],
14634 doors: vec![flatland_protocol::DoorView {
14635 id: "broker_hut_exit".into(),
14636 building_id: "broker_hut".into(),
14637 x: 4.3,
14638 y: 0.9,
14639 open: true,
14640 portal: Some("front".into()),
14641 }],
14642 interior_map: None,
14643 npcs: vec![flatland_protocol::NpcView {
14644 id: "ada_broker".into(),
14645 label: "Ada".into(),
14646 x: 4.5,
14647 y: 2.0,
14648 building_id: Some("broker_hut".into()),
14649 role: "broker".into(),
14650 entity_id: None,
14651 life_state: None,
14652 hp_pct: None,
14653 can_trade: true,
14654 tile_id: None,
14655 behavior_state: None,
14656 presentation_state: None,
14657 sprite_mode: None,
14658 paperdoll_ref: None,
14659 }],
14660 blueprints: vec![],
14661 world_x0: 0.0,
14662 world_y0: 0.0,
14663 world_width_m: 256.0,
14664 world_height_m: 256.0,
14665 terrain_zones: Vec::new(),
14666 z_platforms: Vec::new(),
14667 z_transitions: Vec::new(),
14668 z_bands_outdoor_backup: None,
14669 world_clock: flatland_protocol::WorldClock::default(),
14670 inventory: std::collections::HashMap::new(),
14671 inventory_hints: std::collections::HashMap::new(),
14672 logs: VecDeque::new(),
14673 intents_sent: 0,
14674 ticks_received: 0,
14675 connected: true,
14676 disconnect_reason: None,
14677 show_stats: false,
14678 hud_log_hidden: false,
14679 show_equip_menu: false,
14680 equip_menu_index: 0,
14681 show_craft_menu: false,
14682 craft_menu_index: 0,
14683 craft_batch_quantity: 1,
14684 show_shop_menu: false,
14685 shop_catalog: None,
14686 bank_panel: None,
14687 bank_menu_index: 0,
14688 bank_ui_mode: BankUiMode::Menu,
14689 storage_panel: None,
14690 market_panel: None,
14691 market_menu_index: 0,
14692 market_filter: String::new(),
14693 market_filter_focused: false,
14694 market_category_filter: None,
14695 market_buy_confirm: None,
14696 market_ui_mode: MarketUiMode::Browse,
14697 storage_menu_index: 0,
14698 storage_ui_mode: StorageUiMode::Menu,
14699 shop_tab: ShopTab::default(),
14700 shop_menu_index: 0,
14701 shop_quantity: 1,
14702 shop_trade_log: VecDeque::new(),
14703 show_npc_verb_menu: false,
14704 npc_verb_target: None,
14705 npc_verb_index: 0,
14706 player_verbs: crate::social::PlayerVerbState::default(),
14707 social_chat: crate::social::SocialChatState::default(),
14708 trade_ui: crate::social::TradeUiState::default(),
14709 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
14710 show_npc_chat: false,
14711 npc_chat: None,
14712 show_inventory_menu: false,
14713 inventory_menu_index: 0,
14714 inventory_tab: InventoryTab::OnPerson,
14715 inventory_filter: String::new(),
14716 inventory_filter_focused: false,
14717 show_move_picker: false,
14718 show_rename_prompt: false,
14719 show_worker_rename: false,
14720 rename_buffer: String::new(),
14721 move_picker_index: 0,
14722 move_picker: None,
14723 show_grant_picker: false,
14724 grant_picker_index: 0,
14725 grant_picker: None,
14726 show_destroy_picker: false,
14727 destroy_confirm_pending: false,
14728 destroy_picker: None,
14729 combat_target: None,
14730 combat_target_label: None,
14731 ground_target: None,
14732 combat_fx: Vec::new(),
14733 property_zones: Vec::new(),
14734 tax_zones: Vec::new(),
14735 growth_zones: Vec::new(),
14736 biome_zones: Vec::new(),
14737 property_plots: Vec::new(),
14738 property_plot_settings: None,
14739 claim_mode: None,
14740 relocate_mode: None,
14741 sell_plot_confirm: None,
14742 sell_plot_armed_at: None,
14743 show_plant_menu: false,
14744 plant_menu_index: 0,
14745 show_farm_access: false,
14746 farm_access_name_draft: String::new(),
14747 farm_access_discount_bps: 0,
14748 farm_access_index: 0,
14749 plant_quantity: 1,
14750 in_combat: false,
14751 auto_attack: true,
14752 combat_has_los: false,
14753 attack_cd_ticks: 0,
14754 gcd_ticks: 0,
14755 weapon_ability_id: "unarmed".into(),
14756 mainhand_template_id: None,
14757 mainhand_label: None,
14758 offhand_template_id: None,
14759 offhand_label: None,
14760 mainhand_hand_slots: 1,
14761 defense: None,
14762 worn: BTreeMap::new(),
14763 carry_mass: 0.0,
14764 carry_mass_max: 0.0,
14765 encumbrance: flatland_protocol::EncumbranceState::Light,
14766 inventory_stacks: Vec::new(),
14767 keychain_stacks: Vec::new(),
14768 whisper_pouch_stacks: Vec::new(),
14769 combat_target_detail: None,
14770 statuses: Vec::new(),
14771 cast_progress: None,
14772 timed_channel: None,
14773 ability_cooldowns: Vec::new(),
14774 blocking_active: false,
14775 max_target_slots: 1,
14776 combat_slots: Vec::new(),
14777 rotation_presets: Vec::new(),
14778 known_abilities: Vec::new(),
14779 ability_meta: std::collections::HashMap::new(),
14780 ability_mastery: std::collections::HashMap::new(),
14781 hotbar: vec![None; 9],
14782 max_abilities_per_rotation: 0,
14783 show_loadout_menu: false,
14784 show_keychain_menu: false,
14785 keychain_menu_index: 0,
14786 show_rotation_editor: false,
14787 loadout_menu_index: 0,
14788 loadout_hotbar_slot: 1,
14789 loadout_ability_index: 0,
14790 loadout_focus_presets: false,
14791 rotation_editor: RotationEditorState::default(),
14792 harvest_in_progress: false,
14793 harvest_started_at: None,
14794 pending_craft_ack: None,
14795 pending_worker_job_ack: None,
14796 attending_worker_instance_id: None,
14797 quest_log: Vec::new(),
14798 interactables: Vec::new(),
14799 ledger: None,
14800 career: None,
14801 character_sheet_tab: CharacterSheetTab::Character,
14802 ledger_period: LedgerPeriod::Day,
14803 show_quest_offer: false,
14804 pending_quest_offer: None,
14805 show_quest_menu: false,
14806 quest_menu_index: 0,
14807 quest_withdraw_confirm: false,
14808 hired_workers: Vec::new(),
14809 show_workers_menu: false,
14810 workers_menu_index: 0,
14811 workers_menu_compact: false,
14812 worker_step_display: BTreeMap::new(),
14813 worker_error_display: BTreeMap::new(),
14814 show_worker_give_picker: false,
14815 worker_give_picker_index: 0,
14816 worker_give_picker: None,
14817 show_worker_give_target_picker: false,
14818 worker_give_target_picker_index: 0,
14819 worker_give_target_picker: None,
14820 show_worker_take_picker: false,
14821 worker_take_picker_index: 0,
14822 worker_take_picker: None,
14823 show_worker_teach_picker: false,
14824 worker_teach_picker_index: 0,
14825 worker_teach_picker: None,
14826 worker_route_editor: None,
14827 progression_curve: None,
14828 };
14829 state.player = state.entities.first().cloned();
14830 assert_eq!(
14831 state.nearest_interact_target().as_deref(),
14832 Some("ada_broker")
14833 );
14834 }
14835
14836 #[test]
14837 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
14838 let mut state = sample_state();
14839 state.placed_containers = vec![
14842 flatland_protocol::PlacedContainerView {
14843 id: "near".into(),
14844 template_id: "wooden_chest_small".into(),
14845 display_name: "Wooden Chest".into(),
14846 x: 130.0,
14847 y: 128.0,
14848 z: 0.0,
14849 locked: true,
14850 accessible: true,
14851 owner_character_id: None,
14852 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
14853 lock_id: None,
14854 capacity_volume: None,
14855 item_instance_id: Some(uuid::Uuid::from_u128(1)),
14856 tile_id: None,
14857 worker_lodging_capacity: None,
14858 blocking: false,
14859 blocking_radius_m: 0.0,
14860 },
14861 flatland_protocol::PlacedContainerView {
14862 id: "far".into(),
14863 template_id: "wooden_chest_small".into(),
14864 display_name: "Distant Chest".into(),
14865 x: 128.0 + CONTAINER_RANGE_M + 5.0,
14866 y: 128.0,
14867 z: 0.0,
14868 locked: false,
14869 accessible: true,
14870 owner_character_id: None,
14871 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
14872 lock_id: None,
14873 capacity_volume: None,
14874 item_instance_id: Some(uuid::Uuid::from_u128(2)),
14875 tile_id: None,
14876 worker_lodging_capacity: None,
14877 blocking: false,
14878 blocking_radius_m: 0.0,
14879 },
14880 ];
14881
14882 let nearby = state.nearby_containers();
14883 assert_eq!(
14884 nearby.len(),
14885 1,
14886 "far chest must not appear once out of range"
14887 );
14888 assert_eq!(nearby[0].view.id, "near");
14889 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
14890 assert!(nearby[0].rows[0].is_chest_shell);
14891
14892 state.placed_containers[0].accessible = false;
14895 let nearby = state.nearby_containers();
14896 assert_eq!(nearby.len(), 1);
14897 assert_eq!(nearby[0].rows.len(), 1);
14898 assert!(nearby[0].rows[0].is_chest_shell);
14899 }
14900
14901 #[test]
14902 fn chest_pickup_destinations_offer_person_and_worn_bag() {
14903 let mut state = sample_state();
14904 let back_id = uuid::Uuid::from_u128(42);
14905 state.worn.insert(
14906 BodySlot::Back,
14907 flatland_protocol::ItemStack {
14908 template_id: "travel_backpack".into(),
14909 quantity: 1,
14910 item_instance_id: Some(back_id),
14911 props: Default::default(),
14912 status_bindings: Vec::new(),
14913 contents: Vec::new(),
14914 display_name: Some("Travel Backpack".into()),
14915 category: Some("container".into()),
14916 base_mass: Some(2.5),
14917 base_volume: Some(12.0),
14918 capacity_volume: Some(80.0),
14919 stackable: Some(false),
14920 world_placeable: Some(false),
14921 worker_lodging_capacity: None,
14922 equip_slot: None,
14923 armor_physical: None,
14924 resists: vec![],
14925 hand_slots: None,
14926 listable: None,
14927 },
14928 );
14929 let opts = state.chest_pickup_destinations("chest-1");
14930 assert!(matches!(
14931 opts.first().map(|o| &o.kind),
14932 Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
14933 ));
14934 assert!(opts.iter().any(|o| matches!(
14935 &o.kind,
14936 MoveOptionKind::PickupPlaced {
14937 nest_parent_instance_id: None,
14938 ..
14939 }
14940 )));
14941 assert!(opts.iter().any(|o| matches!(
14942 &o.kind,
14943 MoveOptionKind::PickupPlaced {
14944 nest_parent_instance_id: Some(id),
14945 ..
14946 } if *id == back_id
14947 )));
14948 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
14949 }
14950
14951 #[test]
14952 fn placed_container_public_label_hides_owner_custom_name() {
14953 let owner = uuid::Uuid::from_u128(99);
14954 let mut state = sample_state();
14955 state.character_id = Some(uuid::Uuid::from_u128(1));
14956 state.inventory_hints.insert(
14957 "wooden_chest_medium".into(),
14958 InventoryHint {
14959 display_name: "Medium Wooden Chest".into(),
14960 category: "container".into(),
14961 base_mass: None,
14962 base_volume: None,
14963 capacity_volume: None,
14964 stackable: false,
14965 listable: true,
14966 },
14967 );
14968 let chest = flatland_protocol::PlacedContainerView {
14969 id: "c1".into(),
14970 template_id: "wooden_chest_medium".into(),
14971 display_name: "Barry's Loot #a3f2".into(),
14972 x: 128.0,
14973 y: 128.0,
14974 z: 0.0,
14975 locked: false,
14976 accessible: true,
14977 owner_character_id: Some(owner),
14978 contents: vec![],
14979 lock_id: None,
14980 capacity_volume: None,
14981 item_instance_id: None,
14982 tile_id: None,
14983 worker_lodging_capacity: None,
14984 blocking: false,
14985 blocking_radius_m: 0.0,
14986 };
14987 assert_eq!(
14988 state.placed_container_public_label(&chest),
14989 "Medium Wooden Chest"
14990 );
14991 state.character_id = Some(owner);
14992 assert_eq!(
14993 state.placed_container_public_label(&chest),
14994 "Barry's Loot #a3f2"
14995 );
14996 }
14997
14998 #[test]
14999 fn location_context_shows_crop_growth_percent_not_depleted() {
15000 let mut state = sample_state();
15001 state.player = state.entities.first().cloned();
15002 state.resource_nodes[0].label = "Carrot (growing)".into();
15003 state.resource_nodes[0].x = 128.2;
15004 state.resource_nodes[0].y = 128.0;
15005 state.resource_nodes[0].state = ResourceNodeState::Cooldown;
15006 state.resource_nodes[0].growth_progress = Some(0.47);
15007 let lines = state.location_context_lines();
15008 let line = lines
15009 .iter()
15010 .find(|l| l.text.contains("Carrot"))
15011 .map(|l| l.text.as_str())
15012 .unwrap_or("");
15013 assert!(
15014 line.contains("(growing, 47%)"),
15015 "expected growth percent, got: {line}"
15016 );
15017 assert!(
15018 !line.contains("depleted"),
15019 "growing crop should not show depleted: {line}"
15020 );
15021 }
15022
15023 #[test]
15024 fn resource_node_near_action_suffix_prefers_growth() {
15025 let node = ResourceNodeView {
15026 id: "crop".into(),
15027 label: "Wheat".into(),
15028 x: 0.0,
15029 y: 0.0,
15030 z: 0.0,
15031 item_template: "wheat".into(),
15032 state: ResourceNodeState::Cooldown,
15033 blocking: false,
15034 blocking_radius_m: 0.0,
15035 tile_id: None,
15036 yaw: 0.0,
15037 pitch: 0.0,
15038 roll: 0.0,
15039 draw_scale: 1.0,
15040 sprite_mode: None,
15041 growth_progress: Some(0.12),
15042 presentation_state: None,
15043 channel_start_tick: None,
15044 channel_end_tick: None,
15045 harvest_drop_templates: vec![],
15046 };
15047 assert_eq!(
15048 resource_node_near_action_suffix(&node),
15049 " (growing, 12%)"
15050 );
15051 }
15052
15053 #[test]
15054 fn location_context_lists_nearby_resource_node() {
15055 let mut state = sample_state();
15056 state.player = state.entities.first().cloned();
15057 state.resource_nodes[0].x = 128.2;
15058 state.resource_nodes[0].y = 128.0;
15059 let lines = state.location_context_lines();
15060 assert!(
15061 lines
15062 .iter()
15063 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
15064 "expected resource node in context: {:?}",
15065 lines
15066 );
15067 }
15068
15069 #[test]
15070 fn quest_board_usable_within_board_radius() {
15071 let mut state = sample_state();
15072 state.player = state.entities.first().cloned();
15073 state.interactables = vec![flatland_protocol::InteractableView {
15074 id: "board-1".into(),
15075 kind: "quest_board".into(),
15076 label: "Town Quest Board".into(),
15077 x: 130.5,
15078 y: 128.0,
15079 z: 0.0,
15080 board_id: Some("starter_town_board".into()),
15081 }];
15082 assert_eq!(
15084 state.nearest_interact_target().as_deref(),
15085 Some("board-1"),
15086 "quest board should be selectable at ~2.5m"
15087 );
15088 let lines = state.location_context_lines();
15089 assert!(
15090 lines
15091 .iter()
15092 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
15093 "HUD should advertise f when board is in range: {:?}",
15094 lines
15095 );
15096 }
15097
15098 #[test]
15099 fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
15100 let mut state = sample_state();
15101 state.worn.insert(
15102 BodySlot::Back,
15103 flatland_protocol::ItemStack {
15104 template_id: "travel_backpack".into(),
15105 quantity: 1,
15106 item_instance_id: Some(uuid::Uuid::from_u128(3)),
15107 props: Default::default(),
15108 status_bindings: Vec::new(),
15109 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
15110 display_name: None,
15111 category: None,
15112 base_mass: None,
15113 base_volume: None,
15114 capacity_volume: None,
15115 stackable: None,
15116 world_placeable: None,
15117 worker_lodging_capacity: None,
15118 equip_slot: None,
15119 armor_physical: None,
15120 resists: vec![],
15121 hand_slots: None,
15122 listable: None,
15123 },
15124 );
15125 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
15126 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15127 id: "chest-1".into(),
15128 template_id: "wooden_chest_small".into(),
15129 display_name: "Wooden Chest".into(),
15130 x: 129.0,
15131 y: 128.0,
15132 z: 0.0,
15133 locked: false,
15134 accessible: true,
15135 owner_character_id: None,
15136 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
15137 lock_id: None,
15138 capacity_volume: None,
15139 item_instance_id: Some(uuid::Uuid::from_u128(4)),
15140 tile_id: None,
15141 worker_lodging_capacity: None,
15142 blocking: false,
15143 blocking_radius_m: 0.0,
15144 }];
15145
15146 state.inventory_tab = InventoryTab::OnPerson;
15147 let rows = state.inventory_selectable_rows();
15148 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
15149 assert_eq!(
15150 sections,
15151 vec![
15152 InventorySection::Worn, InventorySection::Worn, InventorySection::Person, ]
15156 );
15157 assert_eq!(rows[0].stack.template_id, "travel_backpack");
15158 assert!(rows[0].is_equip_shell);
15159 assert_eq!(rows[1].stack.template_id, "iron_ore");
15160 assert_eq!(rows[1].depth, 1);
15161 assert_eq!(rows[2].stack.template_id, "lumber");
15162
15163 let lines = state.inventory_browser_lines();
15164 assert!(lines.iter().any(|l| matches!(
15165 l,
15166 InventoryBrowserLine::Section(s) if s.contains("Worn")
15167 )));
15168 assert!(lines.iter().any(|l| matches!(
15169 l,
15170 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
15171 || text.contains("backpack")
15172 )));
15173 assert!(!lines.iter().any(|l| matches!(
15174 l,
15175 InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
15176 )));
15177
15178 state.inventory_tab = InventoryTab::Nearby;
15179 let nearby_rows = state.inventory_selectable_rows();
15180 assert_eq!(nearby_rows.len(), 2);
15181 assert!(nearby_rows[0].is_chest_shell);
15182 assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
15183 let nearby_lines = state.inventory_browser_lines();
15184 assert!(nearby_lines.iter().any(|l| matches!(
15185 l,
15186 InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
15187 )));
15188 }
15189
15190 #[test]
15191 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
15192 let mut state = sample_state();
15193 let back_id = uuid::Uuid::from_u128(5);
15194 state.worn.insert(
15195 BodySlot::Back,
15196 flatland_protocol::ItemStack {
15197 template_id: "travel_backpack".into(),
15198 quantity: 1,
15199 item_instance_id: Some(back_id),
15200 props: Default::default(),
15201 status_bindings: Vec::new(),
15202 contents: Vec::new(),
15203 display_name: None,
15204 category: Some("container".into()),
15205 base_mass: None,
15206 base_volume: None,
15207 capacity_volume: Some(80.0),
15208 stackable: None,
15209 world_placeable: None,
15210 worker_lodging_capacity: None,
15211 equip_slot: None,
15212 armor_physical: None,
15213 resists: vec![],
15214 hand_slots: None,
15215 listable: None,
15216 },
15217 );
15218 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15219 id: "chest-1".into(),
15220 template_id: "wooden_chest_small".into(),
15221 display_name: "Wooden Chest".into(),
15222 x: 129.0,
15223 y: 128.0,
15224 z: 0.0,
15225 locked: false,
15226 accessible: true,
15227 owner_character_id: None,
15228 contents: Vec::new(),
15229 lock_id: None,
15230 capacity_volume: None,
15231 item_instance_id: Some(uuid::Uuid::from_u128(6)),
15232 tile_id: None,
15233 worker_lodging_capacity: None,
15234 blocking: false,
15235 blocking_radius_m: 0.0,
15236 }];
15237
15238 let opts = state.move_destinations_for(
15241 &flatland_protocol::InventoryLocation::Root,
15242 None,
15243 None,
15244 "lumber",
15245 );
15246 assert!(!opts.iter().any(|o| matches!(
15247 &o.kind,
15248 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
15249 )));
15250 assert!(opts.iter().any(|o| matches!(
15251 &o.kind,
15252 MoveOptionKind::Move { location, parent_instance_id, .. }
15253 if *location == flatland_protocol::InventoryLocation::Worn {
15254 slot: BodySlot::Back,
15255 } && *parent_instance_id == Some(back_id)
15256 )));
15257 assert!(opts.iter().any(|o| matches!(
15258 &o.kind,
15259 MoveOptionKind::Move { location, .. }
15260 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
15261 )));
15262 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
15263 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
15264
15265 let from_backpack = flatland_protocol::InventoryLocation::Worn {
15269 slot: BodySlot::Back,
15270 };
15271 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
15272 assert!(!opts.iter().any(|o| matches!(
15273 &o.kind,
15274 MoveOptionKind::Move { location, parent_instance_id, .. }
15275 if *location == from_backpack && *parent_instance_id == Some(back_id)
15276 )));
15277 assert!(opts.iter().any(|o| matches!(
15278 &o.kind,
15279 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
15280 )));
15281 }
15282
15283 #[test]
15284 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
15285 let mut state = sample_state();
15286 state.worn.insert(
15289 BodySlot::Waist,
15290 flatland_protocol::ItemStack {
15291 template_id: "simple_belt".into(),
15292 quantity: 1,
15293 item_instance_id: Some(uuid::Uuid::from_u128(10)),
15294 props: Default::default(),
15295 status_bindings: Vec::new(),
15296 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
15297 display_name: None,
15298 category: Some("container".into()),
15299 base_mass: None,
15300 base_volume: None,
15301 capacity_volume: None,
15302 stackable: None,
15303 world_placeable: None,
15304 worker_lodging_capacity: None,
15305 equip_slot: None,
15306 armor_physical: None,
15307 resists: vec![],
15308 hand_slots: None,
15309 listable: None,
15310 },
15311 );
15312 state.worn.insert(
15313 BodySlot::Head,
15314 flatland_protocol::ItemStack {
15315 template_id: "cloth_cap".into(),
15316 quantity: 1,
15317 item_instance_id: Some(uuid::Uuid::from_u128(11)),
15318 props: Default::default(),
15319 status_bindings: Vec::new(),
15320 contents: Vec::new(),
15321 display_name: None,
15322 category: Some("armor".into()),
15323 base_mass: None,
15324 base_volume: None,
15325 capacity_volume: None,
15326 stackable: None,
15327 world_placeable: None,
15328 worker_lodging_capacity: None,
15329 equip_slot: None,
15330 armor_physical: None,
15331 resists: vec![],
15332 hand_slots: None,
15333 listable: None,
15334 },
15335 );
15336 state.worn.insert(
15337 BodySlot::Back,
15338 flatland_protocol::ItemStack {
15339 template_id: "travel_backpack".into(),
15340 quantity: 1,
15341 item_instance_id: Some(uuid::Uuid::from_u128(12)),
15342 props: Default::default(),
15343 status_bindings: Vec::new(),
15344 contents: Vec::new(),
15345 display_name: None,
15346 category: Some("container".into()),
15347 base_mass: None,
15348 base_volume: None,
15349 capacity_volume: None,
15350 stackable: None,
15351 world_placeable: None,
15352 worker_lodging_capacity: None,
15353 equip_slot: None,
15354 armor_physical: None,
15355 resists: vec![],
15356 hand_slots: None,
15357 listable: None,
15358 },
15359 );
15360
15361 let rows = state.worn_rows();
15362 assert_eq!(rows.len(), 4);
15364 assert_eq!(rows[0].stack.template_id, "cloth_cap");
15365 assert!(rows[0].is_equip_shell);
15366 assert_eq!(rows[1].stack.template_id, "travel_backpack");
15367 assert!(rows[1].is_equip_shell);
15368 assert_eq!(rows[2].stack.template_id, "simple_belt");
15369 assert!(rows[2].is_equip_shell);
15370 assert_eq!(rows[3].stack.template_id, "leather_pouch");
15371 assert_eq!(rows[3].depth, 1);
15372 assert!(!rows[3].is_equip_shell);
15373 }
15374
15375 #[test]
15376 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
15377 let mut state = sample_state();
15378 state.worn.insert(
15379 BodySlot::Waist,
15380 flatland_protocol::ItemStack {
15381 template_id: "simple_belt".into(),
15382 quantity: 1,
15383 item_instance_id: Some(uuid::Uuid::from_u128(20)),
15384 props: Default::default(),
15385 status_bindings: Vec::new(),
15386 contents: Vec::new(),
15387 display_name: Some("Simple Belt".into()),
15388 category: Some("container".into()),
15389 base_mass: None,
15390 base_volume: None,
15391 capacity_volume: None,
15392 stackable: None,
15393 world_placeable: None,
15394 worker_lodging_capacity: None,
15395 equip_slot: None,
15396 armor_physical: None,
15397 resists: vec![],
15398 hand_slots: None,
15399 listable: None,
15400 },
15401 );
15402 state.worn.insert(
15403 BodySlot::Head,
15404 flatland_protocol::ItemStack {
15405 template_id: "cloth_cap".into(),
15406 quantity: 1,
15407 item_instance_id: Some(uuid::Uuid::from_u128(21)),
15408 props: Default::default(),
15409 status_bindings: Vec::new(),
15410 contents: Vec::new(),
15411 display_name: Some("Cloth Cap".into()),
15412 category: Some("armor".into()),
15413 base_mass: None,
15414 base_volume: None,
15415 capacity_volume: None,
15416 stackable: None,
15417 world_placeable: None,
15418 worker_lodging_capacity: None,
15419 equip_slot: None,
15420 armor_physical: None,
15421 resists: vec![],
15422 hand_slots: None,
15423 listable: None,
15424 },
15425 );
15426
15427 let opts = state.move_destinations_for(
15428 &flatland_protocol::InventoryLocation::Root,
15429 None,
15430 None,
15431 "leather_pouch",
15432 );
15433 assert!(
15434 opts.iter().any(|o| matches!(
15435 &o.kind,
15436 MoveOptionKind::Move { location, .. }
15437 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
15438 )),
15439 "belt loop must be offered when moving a pouch"
15440 );
15441 assert!(
15442 !opts.iter().any(|o| matches!(
15443 &o.kind,
15444 MoveOptionKind::Move { location, .. }
15445 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
15446 )),
15447 "armor slots can't hold other items and must not appear as move destinations"
15448 );
15449 let belt_opt = opts
15450 .iter()
15451 .find(|o| matches!(
15452 &o.kind,
15453 MoveOptionKind::Move { location, .. }
15454 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
15455 ))
15456 .unwrap();
15457 assert!(belt_opt.label.contains("belt loop"));
15458
15459 let opts = state.move_destinations_for(
15460 &flatland_protocol::InventoryLocation::Root,
15461 None,
15462 None,
15463 "lumber",
15464 );
15465 assert!(
15466 !opts.iter().any(|o| o.label.contains("belt loop")),
15467 "loose materials must not target the belt shell — only nested pouches"
15468 );
15469 }
15470
15471 #[test]
15472 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
15473 let mut state = sample_state();
15474 let belt_id = uuid::Uuid::from_u128(30);
15475 let pouch_id = uuid::Uuid::from_u128(31);
15476 state.worn.insert(
15477 BodySlot::Waist,
15478 flatland_protocol::ItemStack {
15479 template_id: "simple_belt".into(),
15480 quantity: 1,
15481 item_instance_id: Some(belt_id),
15482 props: Default::default(),
15483 status_bindings: Vec::new(),
15484 world_placeable: None,
15485 worker_lodging_capacity: None,
15486 equip_slot: None,
15487 armor_physical: None,
15488 resists: vec![],
15489 hand_slots: None,
15490 contents: vec![flatland_protocol::ItemStack {
15491 template_id: "dimensional_pouch".into(),
15492 quantity: 1,
15493 item_instance_id: Some(pouch_id),
15494 props: Default::default(),
15495 status_bindings: Vec::new(),
15496 contents: Vec::new(),
15497 display_name: Some("Dimensional Pouch".into()),
15498 category: Some("container".into()),
15499 base_mass: None,
15500 base_volume: None,
15501 capacity_volume: Some(200.0),
15502 stackable: None,
15503 world_placeable: None,
15504 worker_lodging_capacity: None,
15505 equip_slot: None,
15506 armor_physical: None,
15507 resists: vec![],
15508 hand_slots: None,
15509 listable: None,
15510 }],
15511 display_name: Some("Simple Belt".into()),
15512 category: Some("container".into()),
15513 base_mass: None,
15514 base_volume: None,
15515 capacity_volume: None,
15516 stackable: None,
15517 listable: None,
15518 },
15519 );
15520
15521 let opts = state.move_destinations_for(
15522 &flatland_protocol::InventoryLocation::Root,
15523 None,
15524 None,
15525 "iron_ore",
15526 );
15527 assert!(
15528 opts.iter().any(|o| matches!(
15529 &o.kind,
15530 MoveOptionKind::Move {
15531 location,
15532 parent_instance_id,
15533 ..
15534 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
15535 && *parent_instance_id == Some(pouch_id)
15536 )),
15537 "dimensional pouch clipped on belt must accept loose items"
15538 );
15539 assert!(
15540 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
15541 "destination label should name the pouch"
15542 );
15543 }
15544
15545 #[test]
15546 fn container_volume_label_on_placed_chest_shell() {
15547 let mut state = sample_state();
15548 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15549 id: "chest-1".into(),
15550 template_id: "wooden_chest_small".into(),
15551 display_name: "Camp Chest".into(),
15552 x: 129.0,
15553 y: 128.0,
15554 z: 0.0,
15555 locked: false,
15556 accessible: true,
15557 owner_character_id: None,
15558 contents: vec![flatland_protocol::ItemStack {
15559 template_id: "iron_ore".into(),
15560 quantity: 2,
15561 item_instance_id: None,
15562 props: Default::default(),
15563 status_bindings: Vec::new(),
15564 contents: Vec::new(),
15565 display_name: None,
15566 category: None,
15567 base_mass: None,
15568 base_volume: Some(2.0),
15569 capacity_volume: None,
15570 stackable: None,
15571 world_placeable: None,
15572 worker_lodging_capacity: None,
15573 equip_slot: None,
15574 armor_physical: None,
15575 resists: vec![],
15576 hand_slots: None,
15577 listable: None,
15578 }],
15579 lock_id: None,
15580 capacity_volume: Some(60.0),
15581 item_instance_id: Some(uuid::Uuid::from_u128(4)),
15582 tile_id: None,
15583 worker_lodging_capacity: None,
15584 blocking: false,
15585 blocking_radius_m: 0.0,
15586 }];
15587 let nearby = state.nearby_containers();
15588 let label = state.container_volume_label(&nearby[0].rows[0]);
15589 assert!(
15590 label.contains("vol 4/60"),
15591 "expected used/cap in label, got {label}"
15592 );
15593 assert!(
15594 label.contains("56 free"),
15595 "expected free space, got {label}"
15596 );
15597 }
15598
15599 #[test]
15600 fn key_pair_chest_label_from_placed_lock_id() {
15601 let mut state = sample_state();
15602 let owner = uuid::Uuid::from_u128(77);
15603 state.character_id = Some(owner);
15604 let lock = uuid::Uuid::from_u128(99).to_string();
15605 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15606 id: "chest-1".into(),
15607 template_id: "wooden_chest_small".into(),
15608 display_name: "Barry's Loot #a3f2".into(),
15609 x: 129.0,
15610 y: 128.0,
15611 z: 0.0,
15612 locked: true,
15613 accessible: true,
15614 owner_character_id: Some(owner),
15615 contents: Vec::new(),
15616 lock_id: Some(lock.clone()),
15617 capacity_volume: None,
15618 item_instance_id: Some(uuid::Uuid::from_u128(4)),
15619 tile_id: None,
15620 worker_lodging_capacity: None,
15621 blocking: false,
15622 blocking_radius_m: 0.0,
15623 }];
15624 let key_id = uuid::Uuid::from_u128(5);
15625 let key = flatland_protocol::ItemStack {
15626 template_id: KEY_TEMPLATE.into(),
15627 quantity: 1,
15628 item_instance_id: Some(key_id),
15629 props: BTreeMap::from([
15630 (PROP_OPENS_LOCK_ID.into(), lock),
15631 (
15632 PROP_OPENS_CONTAINER_NAME.into(),
15633 "Barry's Loot #a3f2".into(),
15634 ),
15635 ]),
15636 status_bindings: Vec::new(),
15637 contents: Vec::new(),
15638 display_name: Some("Container Key".into()),
15639 category: Some("key".into()),
15640 base_mass: None,
15641 base_volume: None,
15642 capacity_volume: None,
15643 stackable: None,
15644 world_placeable: None,
15645 worker_lodging_capacity: None,
15646 equip_slot: None,
15647 armor_physical: None,
15648 resists: vec![],
15649 hand_slots: None,
15650 listable: None,
15651 };
15652 state.inventory_stacks = vec![key.clone()];
15653 assert_eq!(
15654 state.key_pair_chest_label(&key).as_deref(),
15655 Some("Barry's Loot #a3f2")
15656 );
15657 assert!(state.key_drop_blocked(&key));
15658 }
15659
15660 #[test]
15661 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
15662 let mut state = sample_state();
15663 let lock = uuid::Uuid::from_u128(101).to_string();
15664 let key = flatland_protocol::ItemStack {
15665 template_id: KEY_TEMPLATE.into(),
15666 quantity: 1,
15667 item_instance_id: Some(uuid::Uuid::from_u128(7)),
15668 props: BTreeMap::from([
15669 (PROP_OPENS_LOCK_ID.into(), lock),
15670 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
15671 ]),
15672 status_bindings: Vec::new(),
15673 contents: Vec::new(),
15674 display_name: None,
15675 category: Some("key".into()),
15676 base_mass: None,
15677 base_volume: None,
15678 capacity_volume: None,
15679 stackable: None,
15680 world_placeable: None,
15681 worker_lodging_capacity: None,
15682 equip_slot: None,
15683 armor_physical: None,
15684 resists: vec![],
15685 hand_slots: None,
15686 listable: None,
15687 };
15688 state.placed_containers.clear();
15689 assert_eq!(
15690 state.key_pair_chest_label(&key).as_deref(),
15691 Some("Camp Stash")
15692 );
15693 }
15694
15695 #[test]
15696 fn key_drop_allowed_when_paired_chest_unlocked() {
15697 let mut state = sample_state();
15698 let lock = uuid::Uuid::from_u128(100).to_string();
15699 let key_id = uuid::Uuid::from_u128(6);
15700 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15701 id: "chest-1".into(),
15702 template_id: "wooden_chest_small".into(),
15703 display_name: "Camp Chest".into(),
15704 x: 129.0,
15705 y: 128.0,
15706 z: 0.0,
15707 locked: false,
15708 accessible: true,
15709 owner_character_id: None,
15710 contents: Vec::new(),
15711 lock_id: Some(lock.clone()),
15712 capacity_volume: None,
15713 item_instance_id: None,
15714 tile_id: None,
15715 worker_lodging_capacity: None,
15716 blocking: false,
15717 blocking_radius_m: 0.0,
15718 }];
15719 let key = flatland_protocol::ItemStack {
15720 template_id: KEY_TEMPLATE.into(),
15721 quantity: 1,
15722 item_instance_id: Some(key_id),
15723 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
15724 status_bindings: Vec::new(),
15725 contents: Vec::new(),
15726 display_name: None,
15727 category: Some("key".into()),
15728 base_mass: None,
15729 base_volume: None,
15730 capacity_volume: None,
15731 stackable: None,
15732 world_placeable: None,
15733 worker_lodging_capacity: None,
15734 equip_slot: None,
15735 armor_physical: None,
15736 resists: vec![],
15737 hand_slots: None,
15738 listable: None,
15739 };
15740 state.inventory_stacks = vec![key.clone()];
15741 assert!(!state.key_drop_blocked(&key));
15742 let opts = state.move_destinations_for(
15743 &flatland_protocol::InventoryLocation::Root,
15744 None,
15745 Some(key_id),
15746 KEY_TEMPLATE,
15747 );
15748 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
15749 }
15750
15751 #[test]
15752 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
15753 use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
15754
15755 let mut state = sample_state();
15756 let curve = ProgressionCurve::default();
15757 let bootstrap = ProgressionXp::bootstrap_new(
15758 curve.baseline_display,
15759 curve.xp_base,
15760 curve.xp_growth,
15761 );
15762 let mut fresh = bootstrap.clone();
15763 fresh.strength += 0.08;
15764 if let Some(player) = state.player.as_mut() {
15765 player.progression_xp = Some(bootstrap);
15766 }
15767
15768 let combat = CombatHud {
15769 progression_xp: Some(fresh.clone()),
15770 progression_baseline: curve.baseline_display,
15771 progression_xp_base: curve.xp_base,
15772 progression_xp_growth: curve.xp_growth,
15773 attributes: state.player.as_ref().and_then(|p| p.attributes),
15774 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
15775 ..CombatHud::default()
15776 };
15777 state.apply_combat_hud(&combat);
15778
15779 let xp = state
15780 .player
15781 .as_ref()
15782 .and_then(|p| p.progression_xp.as_ref())
15783 .expect("xp");
15784 assert!((xp.strength - fresh.strength).abs() < 0.001);
15785 assert!(state.progression_curve.is_some());
15786 }
15787
15788 #[test]
15789 fn combat_hud_syncs_known_abilities_and_hotbar() {
15790 use flatland_protocol::CombatHud;
15791
15792 let mut state = sample_state();
15793 let combat = CombatHud {
15794 known_abilities: vec!["unarmed".into(), "fireball".into()],
15795 hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
15796 max_abilities_per_rotation: 4,
15797 ability_id: "short_sword_slash".into(),
15798 ..CombatHud::default()
15799 };
15800 state.apply_combat_hud(&combat);
15801
15802 assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
15803 assert_eq!(state.hotbar_ability(1), Some("fireball"));
15804 assert_eq!(state.hotbar_ability(2), None);
15805 assert_eq!(state.hotbar_ability(3), Some("unarmed"));
15806 assert_eq!(state.max_abilities_per_rotation, 4);
15807 let choices = state.loadout_ability_choices();
15808 assert!(choices.iter().any(|a| a == "short_sword_slash"));
15809 assert!(choices.iter().any(|a| a == "fireball"));
15810 }
15811
15812 #[test]
15813 fn loadout_hotbar_choices_include_inventory_consumables() {
15814 let mut state = sample_state();
15815 state.known_abilities = vec!["unarmed".into()];
15816 state.weapon_ability_id = "unarmed".into();
15817 state.inventory_stacks = vec![flatland_protocol::ItemStack {
15818 template_id: "bottle_of_water".into(),
15819 quantity: 3,
15820 item_instance_id: Some(uuid::Uuid::from_u128(9)),
15821 display_name: Some("Bottle of Water".into()),
15822 category: Some("consumable".into()),
15823 ..Default::default()
15824 }];
15825 state.inventory.insert("bottle_of_water".into(), 3);
15826 state.inventory_hints.insert(
15827 "bottle_of_water".into(),
15828 InventoryHint {
15829 display_name: "Bottle of Water".into(),
15830 category: "consumable".into(),
15831 ..Default::default()
15832 },
15833 );
15834
15835 let choices = state.loadout_hotbar_choices();
15836 assert!(choices.iter().any(|c| c.binding == "unarmed"));
15837 let water = choices
15838 .iter()
15839 .find(|c| c.binding == "item:bottle_of_water")
15840 .expect("water binding");
15841 assert_eq!(water.meta.as_deref(), Some("use"));
15842 assert!(water.label.contains("Water"));
15843 assert_eq!(
15844 state.hotbar_slot_label(1),
15845 None,
15846 "unbound until set"
15847 );
15848 state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
15849 assert_eq!(
15850 state.hotbar_slot_label(5).as_deref(),
15851 Some("Bottle of Water×3")
15852 );
15853 }
15854
15855 #[test]
15856 fn loose_consumable_move_picker_offers_use_and_storage() {
15857 let mut state = sample_state();
15858 let inst = uuid::Uuid::from_u128(77);
15859 state.inventory_stacks = vec![flatland_protocol::ItemStack {
15860 template_id: "carrot".into(),
15861 quantity: 2,
15862 item_instance_id: Some(inst),
15863 props: Default::default(),
15864 status_bindings: Vec::new(),
15865 contents: Vec::new(),
15866 display_name: Some("Wild Carrot".into()),
15867 category: Some("consumable".into()),
15868 base_mass: None,
15869 base_volume: None,
15870 capacity_volume: None,
15871 stackable: Some(true),
15872 world_placeable: None,
15873 worker_lodging_capacity: None,
15874 equip_slot: None,
15875 armor_physical: None,
15876 resists: vec![],
15877 hand_slots: None,
15878 listable: None,
15879 }];
15880 state.inventory_hints.insert(
15881 "carrot".into(),
15882 InventoryHint {
15883 display_name: "Wild Carrot".into(),
15884 category: "consumable".into(),
15885 base_mass: Some(0.15),
15886 base_volume: Some(0.3),
15887 capacity_volume: None,
15888 stackable: true,
15889 listable: true,
15890 },
15891 );
15892 state.show_inventory_menu = true;
15893 state.inventory_menu_index = 0;
15894
15895 let row = state.inventory_selected_row().expect("carrot row");
15896 let mut options = state.move_destinations_for(
15897 &row.from,
15898 row.from_parent_instance_id,
15899 row.stack.item_instance_id,
15900 &row.stack.template_id,
15901 );
15902 if row.from == flatland_protocol::InventoryLocation::Root
15903 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
15904 {
15905 options.insert(
15906 0,
15907 MoveOption {
15908 label: "Use (eat / drink)".into(),
15909 kind: MoveOptionKind::Use,
15910 },
15911 );
15912 }
15913
15914 assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
15915 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
15916 assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
15917 }
15918
15919 #[test]
15920 fn inventory_category_group_order_is_stable() {
15921 assert_eq!(inventory_category_group("weapon").0, "Weapons");
15922 assert_eq!(inventory_category_group("armor").0, "Armor");
15923 assert_eq!(inventory_category_group("consumable").0, "Consumables");
15924 assert_eq!(inventory_category_group("resource").0, "Resources");
15925 assert_eq!(inventory_category_group("container").0, "Containers");
15926 assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
15927 assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
15928 }
15929
15930 #[test]
15931 fn page_list_index_clamps_without_wrap() {
15932 assert_eq!(page_list_index(0, -1, 25), 0);
15933 assert_eq!(page_list_index(0, 1, 25), 10);
15934 assert_eq!(page_list_index(12, 1, 25), 22);
15935 assert_eq!(page_list_index(22, 1, 25), 24);
15936 assert_eq!(page_list_index(5, 1, 0), 0);
15937 assert_eq!(page_list_index(3, -1, 8), 0);
15938 }
15939
15940 #[test]
15941 fn inventory_filter_hides_non_matching_person_items() {
15942 let mut state = sample_state();
15943 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
15944 sword.display_name = Some("Iron Sword".into());
15945 sword.category = Some("weapon".into());
15946 let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
15947 herb.display_name = Some("Wild Herb".into());
15948 herb.category = Some("consumable".into());
15949 state.inventory_stacks = vec![sword, herb];
15950 state.inventory_tab = InventoryTab::OnPerson;
15951 state.inventory_filter = "sword".into();
15952
15953 let rows = state.inventory_selectable_rows();
15954 assert_eq!(rows.len(), 1);
15955 assert_eq!(rows[0].stack.template_id, "iron_sword");
15956
15957 let lines = state.inventory_browser_lines();
15958 assert!(lines.iter().any(|l| matches!(
15959 l,
15960 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
15961 )));
15962 assert!(!lines.iter().any(|l| matches!(
15963 l,
15964 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
15965 )));
15966 }
15967
15968 #[test]
15969 fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
15970 let mut state = sample_state();
15971 let id_a = uuid::Uuid::from_u128(0xa1);
15972 let id_b = uuid::Uuid::from_u128(0xb2);
15973 let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
15974 sword_a.display_name = Some("Iron Sword".into());
15975 sword_a.category = Some("weapon".into());
15976 sword_a.item_instance_id = Some(id_a);
15977 let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
15978 sword_b.display_name = Some("Iron Sword".into());
15979 sword_b.category = Some("weapon".into());
15980 sword_b.item_instance_id = Some(id_b);
15981 state.inventory_stacks = vec![sword_a, sword_b];
15982 state.inventory_tab = InventoryTab::OnPerson;
15983
15984 let lines = state.inventory_browser_lines();
15985 let items: Vec<_> = lines
15986 .iter()
15987 .filter_map(|l| match l {
15988 InventoryBrowserLine::Item {
15989 title,
15990 instance_tooltip,
15991 ..
15992 } => Some((title.clone(), instance_tooltip.clone())),
15993 _ => None,
15994 })
15995 .collect();
15996 assert_eq!(items.len(), 2);
15997 for (title, tip) in &items {
15998 assert!(
15999 !title.contains('#'),
16000 "title should not show instance suffix: {title}"
16001 );
16002 assert!(
16003 tip.is_some(),
16004 "two identical rows should expose instance on hover"
16005 );
16006 }
16007
16008 state.inventory_stacks.pop();
16009 let lines = state.inventory_browser_lines();
16010 let one = lines.iter().find_map(|l| match l {
16011 InventoryBrowserLine::Item {
16012 title,
16013 instance_tooltip,
16014 ..
16015 } => Some((title.clone(), instance_tooltip.clone())),
16016 _ => None,
16017 });
16018 let (title, tip) = one.expect("one sword row");
16019 assert!(!title.contains('#'));
16020 assert!(tip.is_none(), "single row should not need instance tooltip");
16021 }
16022
16023 #[test]
16024 fn inventory_person_rows_group_by_category() {
16025 let mut state = sample_state();
16026 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
16027 sword.category = Some("weapon".into());
16028 sword.display_name = Some("Iron Sword".into());
16029 let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
16030 ore.category = Some("resource".into());
16031 ore.display_name = Some("Iron Ore".into());
16032 let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
16033 potion.category = Some("consumable".into());
16034 potion.display_name = Some("Health Potion".into());
16035 state.inventory_stacks = vec![ore, potion, sword];
16036 state.inventory_tab = InventoryTab::OnPerson;
16037
16038 let lines = state.inventory_browser_lines();
16039 let labels: Vec<&str> = lines
16040 .iter()
16041 .filter_map(|l| match l {
16042 InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
16043 _ => None,
16044 })
16045 .collect();
16046 assert!(
16047 labels.iter().any(|s| s.contains("Weapons")),
16048 "expected Weapons group: {labels:?}"
16049 );
16050 assert!(labels.iter().any(|s| s.contains("Consumables")));
16051 assert!(labels.iter().any(|s| s.contains("Resources")));
16052
16053 let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
16054 let consumable_pos = labels.iter().position(|s| s.contains("Consumables")).unwrap();
16055 let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
16056 assert!(weapon_pos < consumable_pos);
16057 assert!(consumable_pos < resource_pos);
16058 }
16059
16060 #[test]
16061 fn inventory_tab_cycle_resets_selection() {
16062 let mut state = sample_state();
16063 state.inventory_tab = InventoryTab::OnPerson;
16064 state.inventory_menu_index = 3;
16065 state.inventory_tab = state.inventory_tab.cycle(true);
16066 assert_eq!(state.inventory_tab, InventoryTab::Nearby);
16067 assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
16069 assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
16070 assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
16071 assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
16072 }
16073
16074 #[test]
16075 fn parse_bank_copper_amount_blank_and_zero_mean_all() {
16076 assert_eq!(parse_bank_copper_amount(""), Some(0));
16077 assert_eq!(parse_bank_copper_amount(" "), Some(0));
16078 assert_eq!(parse_bank_copper_amount("0"), Some(0));
16079 assert_eq!(parse_bank_copper_amount("250"), Some(250));
16080 assert_eq!(parse_bank_copper_amount("nope"), None);
16081 }
16082
16083 #[test]
16084 fn parse_storage_quantity_blank_and_zero_mean_all() {
16085 assert_eq!(parse_storage_quantity(""), Some(None));
16086 assert_eq!(parse_storage_quantity(" "), Some(None));
16087 assert_eq!(parse_storage_quantity("0"), Some(None));
16088 assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
16089 assert_eq!(parse_storage_quantity("nope"), None);
16090 }
16091
16092 #[test]
16093 fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
16094 assert!(worker_error_is_hud_noise("path stuck — repathing"));
16095 assert!(worker_error_is_hud_noise("path stuck — nudged clear, repathing"));
16096 assert!(worker_error_is_hud_noise("returned to lodging after path failures"));
16097 assert!(!worker_error_is_hud_noise(
16099 "path stuck — no lodging to reset to"
16100 ));
16101 }
16102
16103 #[test]
16104 fn leaving_building_restores_outdoor_z_bands() {
16105 use flatland_protocol::{InteriorMapView, ZPlatformView};
16106
16107 let mut state = sample_state();
16108 state.z_platforms.clear();
16109 state.z_transitions.clear();
16110 state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
16111 state.interior_map = Some(InteriorMapView {
16112 building_id: "broker_hut".into(),
16113 blueprint_id: "broker_hut".into(),
16114 background_color: "#000".into(),
16115 default_floor_color: None,
16116 floor_height_m: 3.0,
16117 z_platforms: vec![ZPlatformView {
16118 id: "floor_0".into(),
16119 z: 0.0,
16120 x0: 0.0,
16121 y0: 0.0,
16122 x1: 8.0,
16123 y1: 8.0,
16124 }],
16125 z_transitions: vec![],
16126 rooms: vec![],
16127 room_doors: vec![],
16128 });
16129 state.sync_interior_map_context();
16130 assert_eq!(state.z_platforms.len(), 1, "indoors installs interior platforms");
16131 assert!(state.z_bands_outdoor_backup.is_some());
16132
16133 state.player.as_mut().unwrap().inside_building = None;
16134 state.sync_interior_map_context();
16135 assert!(
16136 state.z_platforms.is_empty(),
16137 "leaving must restore outdoor bands (empty), not leave interior platforms"
16138 );
16139 assert!(state.z_bands_outdoor_backup.is_none());
16140 assert!(state.interior_map.is_none());
16141 }
16142}