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 pub world_clock: flatland_protocol::WorldClock,
1179 pub inventory: std::collections::HashMap<String, u32>,
1180 pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1181 pub logs: VecDeque<String>,
1182 pub intents_sent: u64,
1183 pub ticks_received: u64,
1184 pub connected: bool,
1185 pub disconnect_reason: Option<String>,
1186 pub show_stats: bool,
1187 pub hud_log_hidden: bool,
1189 pub show_equip_menu: bool,
1190 pub equip_menu_index: usize,
1191 pub show_craft_menu: bool,
1192 pub craft_menu_index: usize,
1193 pub craft_batch_quantity: u32,
1195 pub show_shop_menu: bool,
1196 pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1197 pub bank_panel: Option<flatland_protocol::BankPanel>,
1198 pub bank_menu_index: usize,
1199 pub bank_ui_mode: BankUiMode,
1200 pub storage_panel: Option<flatland_protocol::StoragePanel>,
1201 pub market_panel: Option<flatland_protocol::MarketPanel>,
1202 pub market_menu_index: usize,
1204 pub market_filter: String,
1206 pub market_filter_focused: bool,
1207 pub market_category_filter: Option<&'static str>,
1209 pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1211 pub market_ui_mode: MarketUiMode,
1212 pub storage_menu_index: usize,
1213 pub storage_ui_mode: StorageUiMode,
1214 pub shop_tab: ShopTab,
1215 pub shop_menu_index: usize,
1216 pub shop_quantity: u32,
1217 pub shop_trade_log: VecDeque<String>,
1219 pub show_npc_verb_menu: bool,
1220 pub npc_verb_target: Option<String>,
1221 pub npc_verb_index: usize,
1222 pub player_verbs: crate::social::PlayerVerbState,
1224 pub social_chat: crate::social::SocialChatState,
1225 pub trade_ui: crate::social::TradeUiState,
1226 pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1227 pub show_npc_chat: bool,
1228 pub npc_chat: Option<NpcChatState>,
1229 pub show_inventory_menu: bool,
1230 pub inventory_menu_index: usize,
1231 pub inventory_tab: InventoryTab,
1232 pub inventory_filter: String,
1233 pub inventory_filter_focused: bool,
1234 pub show_move_picker: bool,
1235 pub move_picker_index: usize,
1236 pub move_picker: Option<MovePicker>,
1237 pub show_grant_picker: bool,
1238 pub grant_picker_index: usize,
1239 pub grant_picker: Option<GrantTargetPicker>,
1240 pub show_destroy_picker: bool,
1241 pub destroy_confirm_pending: bool,
1242 pub destroy_picker: Option<DestroyPicker>,
1243 pub show_rename_prompt: bool,
1245 pub show_worker_rename: bool,
1247 pub rename_buffer: String,
1248 pub combat_target: Option<EntityId>,
1250 pub combat_target_label: Option<String>,
1251 pub combat_fx: Vec<flatland_protocol::CombatFx>,
1253 pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1255 pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1257 pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1259 pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1261 pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1263 pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1265 pub claim_mode: Option<ClaimModeState>,
1267 pub relocate_mode: Option<RelocateModeState>,
1269 pub sell_plot_confirm: Option<uuid::Uuid>,
1271 pub sell_plot_armed_at: Option<Instant>,
1273 pub show_plant_menu: bool,
1275 pub plant_menu_index: usize,
1276 pub show_farm_access: bool,
1278 pub farm_access_name_draft: String,
1280 pub farm_access_discount_bps: u32,
1282 pub farm_access_index: usize,
1284 pub plant_quantity: u32,
1285 pub in_combat: bool,
1286 pub auto_attack: bool,
1287 pub combat_has_los: bool,
1288 pub attack_cd_ticks: u64,
1289 pub gcd_ticks: u64,
1290 pub weapon_ability_id: String,
1291 pub mainhand_template_id: Option<String>,
1292 pub mainhand_label: Option<String>,
1293 pub offhand_template_id: Option<String>,
1294 pub offhand_label: Option<String>,
1295 pub mainhand_hand_slots: u8,
1296 pub defense: Option<flatland_protocol::DefenseHud>,
1297 pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1299 pub carry_mass: f32,
1300 pub carry_mass_max: f32,
1301 pub encumbrance: flatland_protocol::EncumbranceState,
1302 pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1304 pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1306 pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1308 pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1310 pub combat_target_detail: Option<CombatTargetHud>,
1311 pub cast_progress: Option<CastProgressHud>,
1312 pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1314 pub ability_cooldowns: Vec<AbilityCooldownHud>,
1315 pub blocking_active: bool,
1316 pub max_target_slots: u8,
1317 pub combat_slots: Vec<CombatSlotHud>,
1318 pub rotation_presets: Vec<RotationPreset>,
1319 pub known_abilities: Vec<String>,
1321 pub hotbar: Vec<Option<String>>,
1323 pub max_abilities_per_rotation: u8,
1325 pub show_loadout_menu: bool,
1326 pub show_keychain_menu: bool,
1327 pub keychain_menu_index: usize,
1328 pub show_rotation_editor: bool,
1329 pub loadout_menu_index: usize,
1331 pub loadout_hotbar_slot: u8,
1333 pub loadout_ability_index: usize,
1335 pub loadout_focus_presets: bool,
1337 pub rotation_editor: RotationEditorState,
1338 pub harvest_in_progress: bool,
1340 pub harvest_started_at: Option<Instant>,
1342 pub pending_craft_ack: Option<(u32, String, u32)>,
1344 pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1345 pub interactables: Vec<flatland_protocol::InteractableView>,
1346 pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1347 pub career: Option<flatland_protocol::PlayerCareerView>,
1348 pub character_sheet_tab: CharacterSheetTab,
1349 pub ledger_period: LedgerPeriod,
1350 pub show_quest_offer: bool,
1351 pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1352 pub show_quest_menu: bool,
1353 pub quest_menu_index: usize,
1354 pub quest_withdraw_confirm: bool,
1355 pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1356 pub show_workers_menu: bool,
1357 pub workers_menu_index: usize,
1358 pub workers_menu_compact: bool,
1360 pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1363 pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1365 pub show_worker_give_picker: bool,
1367 pub worker_give_picker_index: usize,
1368 pub worker_give_picker: Option<WorkerGivePicker>,
1369 pub show_worker_give_target_picker: bool,
1371 pub worker_give_target_picker_index: usize,
1372 pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1373 pub show_worker_take_picker: bool,
1375 pub worker_take_picker_index: usize,
1376 pub worker_take_picker: Option<WorkerTakePicker>,
1377 pub show_worker_teach_picker: bool,
1379 pub worker_teach_picker_index: usize,
1380 pub worker_teach_picker: Option<WorkerTeachPicker>,
1381 pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1383 pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1385 pub attending_worker_instance_id: Option<String>,
1387 pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1389}
1390
1391impl GameState {
1392 pub fn push_log(&mut self, line: impl Into<String>) {
1393 self.logs.push_back(line.into());
1394 while self.logs.len() > MAX_LOG_LINES {
1395 self.logs.pop_front();
1396 }
1397 }
1398
1399 pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1400 self.shop_trade_log.push_back(line.into());
1401 while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1402 self.shop_trade_log.pop_front();
1403 }
1404 }
1405
1406 pub fn clear_shop_trade_log(&mut self) {
1407 self.shop_trade_log.clear();
1408 }
1409
1410 fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1411 if !self.show_shop_menu {
1412 return;
1413 }
1414 let msg = notice.message.trim();
1415 if msg.is_empty() {
1416 return;
1417 }
1418 if notice.coins_delta != 0
1419 || msg.starts_with("Bought ")
1420 || msg.starts_with("Sold ")
1421 || msg.contains("taught you how to craft")
1422 || msg.starts_with("need ")
1423 {
1424 self.push_shop_trade_log(msg);
1425 }
1426 }
1427
1428 pub fn is_alive(&self) -> bool {
1429 self.player
1430 .as_ref()
1431 .and_then(|p| p.vitals)
1432 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1433 .unwrap_or(true)
1434 }
1435
1436 pub fn npc_verb_options(&self) -> Vec<&'static str> {
1438 let Some(ref id) = self.npc_verb_target else {
1439 return vec![];
1440 };
1441 let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1442 return vec!["Talk"];
1443 };
1444 let role = npc.role.as_str();
1445 if Self::npc_role_is_bank(role) {
1446 return vec!["Bank", "Talk"];
1447 }
1448 if Self::npc_role_is_storage(role) {
1449 return vec!["Storage", "Talk"];
1450 }
1451 if Self::npc_role_is_market(role) {
1452 return vec!["Market", "Talk"];
1453 }
1454 if npc.can_trade || Self::npc_role_can_trade(role) {
1455 vec!["Talk", "Trade"]
1456 } else {
1457 vec!["Talk"]
1458 }
1459 }
1460
1461 fn npc_role_can_trade(role: &str) -> bool {
1462 matches!(role, "broker" | "cook" | "farmer" | "merchant")
1463 }
1464
1465 fn npc_role_is_bank(role: &str) -> bool {
1466 role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1467 }
1468
1469 fn npc_role_is_storage(role: &str) -> bool {
1470 role.eq_ignore_ascii_case("storage_manager")
1471 }
1472
1473 fn npc_role_is_market(role: &str) -> bool {
1474 role.eq_ignore_ascii_case("market_clerk")
1475 }
1476
1477 pub fn bank_menu_options(&self) -> Vec<&'static str> {
1478 vec![
1479 "Deposit…",
1480 "Withdraw…",
1481 "Deposit all",
1482 "Withdraw all",
1483 "Transfer…",
1484 ]
1485 }
1486
1487 pub fn storage_menu_options(&self) -> Vec<String> {
1488 let mut opts = vec!["Store…".into(), "Take…".into()];
1489 if let Some(panel) = &self.storage_panel {
1490 for dest in &panel.ship_destinations {
1491 opts.push(format!(
1492 "Ship → {} ({} cp / {} ticks)",
1493 dest.label, dest.fee_copper, dest.travel_ticks
1494 ));
1495 }
1496 }
1497 opts
1498 }
1499
1500 pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1502 self.person_rows()
1503 .into_iter()
1504 .filter(|r| r.depth == 0)
1505 .filter_map(|r| {
1506 let id = r.stack.item_instance_id?;
1507 Some(StoragePickOption {
1508 item_instance_id: id,
1509 label: storage_stack_label(&r.stack),
1510 quantity: r.stack.quantity,
1511 category: r.stack.category.clone().unwrap_or_default(),
1512 })
1513 })
1514 .collect()
1515 }
1516
1517 pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1519 let Some(panel) = &self.storage_panel else {
1520 return Vec::new();
1521 };
1522 panel
1523 .contents
1524 .iter()
1525 .filter_map(|s| {
1526 let id = s.item_instance_id?;
1527 Some(StoragePickOption {
1528 item_instance_id: id,
1529 label: storage_stack_label(s),
1530 quantity: s.quantity,
1531 category: s.category.clone().unwrap_or_default(),
1532 })
1533 })
1534 .collect()
1535 }
1536
1537 pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
1539 let mut opts = Vec::new();
1540 if !self
1541 .market_list_item_options(&MarketListSourceKind::Person)
1542 .is_empty()
1543 {
1544 opts.push((MarketListSourceKind::Person, "On person".into()));
1545 }
1546 if let Some(panel) = &self.market_panel {
1547 for vault in &panel.list_vaults {
1548 let source = MarketListSourceKind::TownStorage {
1549 building_id: vault.building_id.clone(),
1550 };
1551 if self.market_list_item_options(&source).is_empty() {
1552 continue;
1553 }
1554 let label = if vault.building_label.is_empty() {
1555 format!("Town storage ({})", vault.building_id)
1556 } else {
1557 format!("Town storage — {}", vault.building_label)
1558 };
1559 opts.push((source, label));
1560 }
1561 }
1562 opts
1563 }
1564
1565 pub fn market_list_item_options(
1567 &self,
1568 source: &MarketListSourceKind,
1569 ) -> Vec<StoragePickOption> {
1570 let filter = self.market_filter.as_str();
1571 let cat_filter = self.market_category_filter;
1572 let mut opts: Vec<StoragePickOption> = match source {
1573 MarketListSourceKind::Person => self
1574 .person_rows()
1575 .into_iter()
1576 .filter(|r| r.depth == 0)
1577 .filter(|r| self.stack_is_market_listable(&r.stack))
1578 .filter_map(|r| {
1579 let id = r.stack.item_instance_id?;
1580 Some(StoragePickOption {
1581 item_instance_id: id,
1582 label: storage_stack_label(&r.stack),
1583 quantity: r.stack.quantity,
1584 category: r
1585 .stack
1586 .category
1587 .clone()
1588 .or_else(|| {
1589 self.inventory_item_category(&r.stack.template_id)
1590 .map(str::to_string)
1591 })
1592 .unwrap_or_default(),
1593 })
1594 })
1595 .collect(),
1596 MarketListSourceKind::TownStorage { building_id } => {
1597 let Some(panel) = &self.market_panel else {
1598 return Vec::new();
1599 };
1600 let Some(vault) = panel
1601 .list_vaults
1602 .iter()
1603 .find(|v| &v.building_id == building_id)
1604 else {
1605 return Vec::new();
1606 };
1607 vault
1608 .contents
1609 .iter()
1610 .filter(|s| self.stack_is_market_listable(s))
1611 .filter_map(|s| {
1612 let id = s.item_instance_id?;
1613 Some(StoragePickOption {
1614 item_instance_id: id,
1615 label: storage_stack_label(s),
1616 quantity: s.quantity,
1617 category: s
1618 .category
1619 .clone()
1620 .or_else(|| {
1621 self.inventory_item_category(&s.template_id)
1622 .map(str::to_string)
1623 })
1624 .unwrap_or_default(),
1625 })
1626 })
1627 .collect()
1628 }
1629 };
1630 opts.retain(|o| {
1631 if !list_label_matches(&o.label, filter) {
1632 return false;
1633 }
1634 if let Some(group) = cat_filter {
1635 inventory_category_group(&o.category).0 == group
1636 } else {
1637 true
1638 }
1639 });
1640 opts
1641 }
1642
1643 fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
1644 if crate::currency::is_currency(&stack.template_id) {
1645 return false;
1646 }
1647 if let Some(flag) = stack.listable {
1648 return flag;
1649 }
1650 if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
1651 return hint.listable;
1652 }
1653 let cat = stack
1654 .category
1655 .as_deref()
1656 .or_else(|| self.inventory_item_category(&stack.template_id))
1657 .unwrap_or("");
1658 category_default_listable(cat)
1659 }
1660
1661 pub fn market_available_category_groups(&self) -> Vec<&'static str> {
1663 let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
1664 match &self.market_ui_mode {
1665 MarketUiMode::ListPick { source, .. } => {
1666 let raw: Vec<_> = match source {
1667 MarketListSourceKind::Person => self
1668 .person_rows()
1669 .into_iter()
1670 .filter(|r| r.depth == 0)
1671 .filter(|r| self.stack_is_market_listable(&r.stack))
1672 .filter(|r| list_label_matches(&storage_stack_label(&r.stack), &self.market_filter))
1673 .map(|r| {
1674 r.stack
1675 .category
1676 .clone()
1677 .or_else(|| {
1678 self.inventory_item_category(&r.stack.template_id)
1679 .map(str::to_string)
1680 })
1681 .unwrap_or_default()
1682 })
1683 .collect(),
1684 MarketListSourceKind::TownStorage { building_id } => self
1685 .market_panel
1686 .as_ref()
1687 .and_then(|p| {
1688 p.list_vaults
1689 .iter()
1690 .find(|v| &v.building_id == building_id)
1691 })
1692 .map(|vault| {
1693 vault
1694 .contents
1695 .iter()
1696 .filter(|s| self.stack_is_market_listable(s))
1697 .filter(|s| {
1698 list_label_matches(&storage_stack_label(s), &self.market_filter)
1699 })
1700 .map(|s| {
1701 s.category
1702 .clone()
1703 .or_else(|| {
1704 self.inventory_item_category(&s.template_id)
1705 .map(str::to_string)
1706 })
1707 .unwrap_or_default()
1708 })
1709 .collect::<Vec<_>>()
1710 })
1711 .unwrap_or_default(),
1712 };
1713 for category in raw {
1714 let (label, ord) = inventory_category_group(&category);
1715 seen.insert(ord, label);
1716 }
1717 }
1718 _ => {
1719 if let Some(panel) = &self.market_panel {
1720 for listing in &panel.listings {
1721 if !list_label_matches(&listing.display_name, &self.market_filter)
1722 && !list_label_matches(&listing.seller_label, &self.market_filter)
1723 {
1724 continue;
1725 }
1726 let (label, ord) = inventory_category_group(&listing.category);
1727 seen.insert(ord, label);
1728 }
1729 }
1730 }
1731 }
1732 seen.into_values().collect()
1733 }
1734
1735 pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
1737 let Some(panel) = &self.market_panel else {
1738 return Vec::new();
1739 };
1740 let filter = self.market_filter.as_str();
1741 let cat_filter = self.market_category_filter;
1742 panel
1743 .listings
1744 .iter()
1745 .enumerate()
1746 .filter(|(_, listing)| {
1747 if !list_label_matches(&listing.display_name, filter)
1748 && !list_label_matches(&listing.seller_label, filter)
1749 && !list_label_matches(&listing.template_id, filter)
1750 {
1751 return false;
1752 }
1753 if let Some(group) = cat_filter {
1754 inventory_category_group(&listing.category).0 == group
1755 } else {
1756 true
1757 }
1758 })
1759 .map(|(i, _)| i)
1760 .collect()
1761 }
1762
1763 pub fn clear_harvest_state(&mut self) {
1764 self.harvest_in_progress = false;
1765 self.harvest_started_at = None;
1766 }
1767
1768 fn harvest_state_stale(&self) -> bool {
1769 match self.harvest_started_at {
1770 Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
1771 None => self.harvest_in_progress,
1772 }
1773 }
1774
1775 pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
1776 self.player.as_ref().and_then(|p| p.vitals)
1777 }
1778
1779 pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
1780 let materials_ok = blueprint.inputs.iter().all(|input| {
1781 self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
1782 });
1783 let tools_ok = blueprint
1784 .required_tools
1785 .iter()
1786 .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
1787 let station_ok = match blueprint.station.as_deref() {
1788 None | Some("hand") => true,
1789 Some(tag) => self.player_at_station_tag(tag),
1790 };
1791 materials_ok && tools_ok && station_ok
1792 }
1793
1794 pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
1795 if !self.can_craft_blueprint(blueprint) {
1796 return 0;
1797 }
1798 let mut limit = u32::MAX;
1799 for input in &blueprint.inputs {
1800 if input.quantity == 0 {
1801 continue;
1802 }
1803 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1804 limit = limit.min(have / input.quantity);
1805 }
1806 for tool in &blueprint.required_tools {
1807 if tool.consumed {
1808 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1809 limit = limit.min(have);
1810 }
1811 }
1812 let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
1813 if CRAFT_STAMINA_COST > 0.0 {
1814 limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
1815 }
1816 limit
1817 }
1818
1819 pub fn clamp_craft_batch_quantity(&mut self) {
1820 let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
1821 self.craft_batch_quantity = 1;
1822 return;
1823 };
1824 let max = self.max_craft_batches(bp).max(1);
1825 self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
1826 }
1827
1828 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
1829 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1830 return;
1831 };
1832 let max = self.max_craft_batches(&bp).max(1);
1833 let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
1834 self.craft_batch_quantity = next as u32;
1835 }
1836
1837 pub fn craft_batch_set_max(&mut self) {
1838 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1839 return;
1840 };
1841 let max = self.max_craft_batches(&bp);
1842 self.craft_batch_quantity = if max == 0 { 1 } else { max };
1843 }
1844
1845 pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
1846 let preserve_ui = self.show_shop_menu;
1847 let tab = self.shop_tab;
1848 let index = self.shop_menu_index;
1849 let qty = self.shop_quantity;
1850
1851 self.show_shop_menu = true;
1852 self.bank_panel = None;
1853 self.show_craft_menu = false;
1854 self.show_inventory_menu = false;
1855 self.show_stats = false;
1856 if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
1857 self.npc_verb_target = Some(catalog.npc_id.clone());
1858 }
1859 self.shop_catalog = Some(catalog);
1860
1861 if preserve_ui {
1862 self.shop_tab = tab;
1863 self.shop_menu_index = index;
1864 self.shop_quantity = qty;
1865 } else {
1866 self.shop_tab = ShopTab::Buy;
1867 self.shop_menu_index = 0;
1868 self.shop_quantity = 1;
1869 self.clear_shop_trade_log();
1870 }
1871 self.show_npc_verb_menu = false;
1872 self.clamp_shop_selection();
1873 }
1874
1875 pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
1876 let same_teller = self
1877 .bank_panel
1878 .as_ref()
1879 .is_some_and(|p| p.npc_id == panel.npc_id);
1880 self.bank_panel = Some(panel);
1881 self.storage_panel = None;
1882 self.market_panel = None;
1883 self.shop_catalog = None;
1884 self.show_shop_menu = false;
1885 self.show_craft_menu = false;
1886 self.show_inventory_menu = false;
1887 self.show_stats = false;
1888 self.show_npc_verb_menu = false;
1889 self.show_npc_chat = false;
1890 self.npc_chat = None;
1891 if !same_teller {
1892 self.bank_menu_index = 0;
1893 self.bank_ui_mode = BankUiMode::Menu;
1894 }
1895 if let Some(panel) = &self.bank_panel {
1896 if self.npc_verb_target.is_none() {
1897 self.npc_verb_target = Some(panel.npc_id.clone());
1898 }
1899 }
1900 }
1901
1902 pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
1903 let same_manager = self
1904 .storage_panel
1905 .as_ref()
1906 .is_some_and(|p| p.npc_id == panel.npc_id);
1907 self.storage_panel = Some(panel);
1908 self.bank_panel = None;
1909 self.market_panel = None;
1910 self.bank_ui_mode = BankUiMode::Menu;
1911 self.shop_catalog = None;
1912 self.show_shop_menu = false;
1913 self.show_craft_menu = false;
1914 self.show_inventory_menu = false;
1915 self.show_stats = false;
1916 self.show_npc_verb_menu = false;
1917 self.show_npc_chat = false;
1918 self.npc_chat = None;
1919 if !same_manager {
1920 self.storage_menu_index = 0;
1921 self.storage_ui_mode = StorageUiMode::Menu;
1922 } else {
1923 self.clamp_storage_pick_index();
1924 }
1925 if let Some(panel) = &self.storage_panel {
1926 if self.npc_verb_target.is_none() {
1927 self.npc_verb_target = Some(panel.npc_id.clone());
1928 }
1929 }
1930 }
1931
1932 pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
1933 self.market_panel = Some(panel);
1934 self.bank_panel = None;
1935 self.storage_panel = None;
1936 self.shop_catalog = None;
1937 self.show_shop_menu = false;
1938 self.show_craft_menu = false;
1939 self.show_inventory_menu = false;
1940 self.show_stats = false;
1941 self.show_npc_verb_menu = false;
1942 self.show_npc_chat = false;
1943 self.npc_chat = None;
1944 self.market_menu_index = 0;
1945 self.market_buy_confirm = None;
1946 self.market_ui_mode = MarketUiMode::Browse;
1947 self.market_filter.clear();
1948 self.market_filter_focused = false;
1949 self.market_category_filter = None;
1950 if let Some(panel) = &self.market_panel {
1951 if self.npc_verb_target.is_none() {
1952 self.npc_verb_target = Some(panel.npc_id.clone());
1953 }
1954 }
1955 }
1956
1957 pub fn clear_market_panel(&mut self) {
1958 self.market_panel = None;
1959 self.market_menu_index = 0;
1960 self.market_buy_confirm = None;
1961 self.market_ui_mode = MarketUiMode::Browse;
1962 self.market_filter.clear();
1963 self.market_filter_focused = false;
1964 self.market_category_filter = None;
1965 }
1966
1967 pub fn clear_bank_panel(&mut self) {
1968 self.bank_panel = None;
1969 self.bank_menu_index = 0;
1970 self.bank_ui_mode = BankUiMode::Menu;
1971 }
1972
1973 pub fn clear_storage_panel(&mut self) {
1974 self.storage_panel = None;
1975 self.storage_menu_index = 0;
1976 self.storage_ui_mode = StorageUiMode::Menu;
1977 }
1978
1979 fn clamp_storage_pick_index(&mut self) {
1980 match &self.storage_ui_mode {
1981 StorageUiMode::StorePick { index } => {
1982 let n = self.storage_store_options().len();
1983 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1984 self.storage_ui_mode = StorageUiMode::StorePick { index: next };
1985 }
1986 StorageUiMode::TakePick { index } => {
1987 let n = self.storage_vault_options().len();
1988 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1989 self.storage_ui_mode = StorageUiMode::TakePick { index: next };
1990 }
1991 StorageUiMode::ShipPick {
1992 dest_building_id,
1993 dest_label,
1994 index,
1995 } => {
1996 let n = self.storage_vault_options().len();
1997 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1998 self.storage_ui_mode = StorageUiMode::ShipPick {
1999 dest_building_id: dest_building_id.clone(),
2000 dest_label: dest_label.clone(),
2001 index: next,
2002 };
2003 }
2004 StorageUiMode::Menu
2005 | StorageUiMode::StoreAmount { .. }
2006 | StorageUiMode::TakeAmount { .. }
2007 | StorageUiMode::ShipAmount { .. } => {}
2008 }
2009 }
2010
2011 pub fn shop_list_len(&self) -> usize {
2012 let Some(catalog) = &self.shop_catalog else {
2013 return 0;
2014 };
2015 match self.shop_tab {
2016 ShopTab::Buy => catalog.sells.len(),
2017 ShopTab::Sell => catalog.buys.len(),
2018 }
2019 }
2020
2021 pub fn shop_menu_move(&mut self, delta: i32) {
2022 let n = self.shop_list_len();
2023 if n == 0 {
2024 return;
2025 }
2026 let idx = self.shop_menu_index as i32;
2027 let next = (idx + delta).rem_euclid(n as i32);
2028 self.shop_menu_index = next as usize;
2029 self.clamp_shop_quantity();
2030 }
2031
2032 pub fn shop_quantity_adjust(&mut self, delta: i32) {
2033 let max = self.shop_quantity_max();
2034 if max == 0 {
2035 self.shop_quantity = 0;
2036 return;
2037 }
2038 let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
2039 self.shop_quantity = next as u32;
2040 }
2041
2042 pub(crate) fn clamp_shop_selection(&mut self) {
2043 let n = self.shop_list_len();
2044 if n == 0 {
2045 self.shop_menu_index = 0;
2046 } else {
2047 self.shop_menu_index = self.shop_menu_index.min(n - 1);
2048 }
2049 self.clamp_shop_quantity();
2050 }
2051
2052 fn shop_quantity_max(&self) -> u32 {
2053 let Some(catalog) = &self.shop_catalog else {
2054 return 1;
2055 };
2056 match self.shop_tab {
2057 ShopTab::Buy => {
2058 if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
2059 if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
2060 return 1;
2061 }
2062 }
2063 99
2064 }
2065 ShopTab::Sell => catalog
2066 .buys
2067 .get(self.shop_menu_index)
2068 .map(|l| l.quantity)
2069 .unwrap_or(0),
2070 }
2071 }
2072
2073 pub fn shop_quantity_set_max(&mut self) {
2074 self.shop_quantity = self.shop_quantity_max();
2075 }
2076
2077 fn clamp_shop_quantity(&mut self) {
2078 let max = self.shop_quantity_max();
2079 if max == 0 {
2080 self.shop_quantity = 0;
2081 } else {
2082 self.shop_quantity = self.shop_quantity.max(1).min(max);
2083 }
2084 }
2085
2086 pub fn player_at_station_tag(&self, tag: &str) -> bool {
2087 let Some(id) = self.effective_inside_building() else {
2088 return false;
2089 };
2090 self.buildings
2091 .iter()
2092 .find(|b| b.id == id)
2093 .is_some_and(|b| b.tags.iter().any(|t| t == tag))
2094 }
2095
2096 pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
2098 if self.can_craft_blueprint(blueprint) {
2099 return None;
2100 }
2101 let mut missing = Vec::new();
2102 for input in &blueprint.inputs {
2103 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2104 if have < input.quantity {
2105 missing.push(format!(
2106 "{}×{} (have {have})",
2107 input.quantity, input.template_id
2108 ));
2109 }
2110 }
2111 for tool in &blueprint.required_tools {
2112 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2113 if have < 1 {
2114 missing.push(format!("tool: {}", tool.item));
2115 }
2116 }
2117 if let Some(station) = blueprint.station.as_deref() {
2118 if station != "hand" && !self.player_at_station_tag(station) {
2119 missing.push(format!("station: {station} (enter building)"));
2120 }
2121 }
2122 if missing.is_empty() {
2123 None
2124 } else {
2125 Some(missing.join(", "))
2126 }
2127 }
2128
2129 pub fn player_entity(&self) -> Option<&EntityState> {
2130 self.player
2131 .as_ref()
2132 .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
2133 }
2134
2135 pub fn apply_client_ui_prefs(&mut self) {
2137 let cfg = crate::client_config::ClientConfig::load();
2138 if let Some(hidden) = cfg.hud_log_hidden {
2139 self.hud_log_hidden = hidden;
2140 }
2141 if let Some(compact) = cfg.workers_menu_compact {
2142 self.workers_menu_compact = compact;
2143 }
2144 }
2145
2146 pub fn player_position(&self) -> (f32, f32) {
2147 let (x, y, _) = self.player_position_with_z();
2148 (x, y)
2149 }
2150
2151 pub fn player_position_with_z(&self) -> (f32, f32, f32) {
2152 if let Some(p) = self.player_entity() {
2153 (
2154 p.transform.position.x,
2155 p.transform.position.y,
2156 p.transform.position.z,
2157 )
2158 } else {
2159 (0.0, 0.0, 0.0)
2160 }
2161 }
2162
2163 pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
2164 let mut rows: Vec<(String, u32, String)> = self
2165 .inventory
2166 .iter()
2167 .filter(|(_, q)| **q > 0)
2168 .map(|(id, qty)| {
2169 let label = self
2170 .inventory_hints
2171 .get(id)
2172 .map(|h| h.display_name.clone())
2173 .unwrap_or_else(|| id.clone());
2174 (id.clone(), *qty, label)
2175 })
2176 .collect();
2177 rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
2178 rows
2179 }
2180
2181 pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
2182 self.inventory_hints
2183 .get(template_id)
2184 .map(|h| h.category.as_str())
2185 .filter(|c| !c.is_empty())
2186 }
2187
2188 pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
2189 stack
2190 .props
2191 .get("grants_item_status_effect")
2192 .map(|s| !s.is_empty())
2193 .unwrap_or(false)
2194 }
2195
2196 pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
2197 stack
2198 .props
2199 .get("grants_item_status_effect")
2200 .map(String::as_str)
2201 .filter(|s| !s.is_empty())
2202 }
2203
2204 pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
2205 stack
2206 .props
2207 .get("grants_item_status_mode")
2208 .map(String::as_str)
2209 .unwrap_or("on_hit")
2210 }
2211
2212 pub fn grant_target_options(
2214 &self,
2215 grant: &flatland_protocol::ItemStack,
2216 ) -> Vec<GrantTargetOption> {
2217 let mode = Self::grant_mode(grant);
2218 let grant_tags: Vec<&str> = grant
2219 .props
2220 .get("grants_item_status_tags")
2221 .map(|s| {
2222 s.split(',')
2223 .map(str::trim)
2224 .filter(|t| !t.is_empty())
2225 .collect()
2226 })
2227 .unwrap_or_default();
2228 let grant_id = grant.item_instance_id;
2229 let mut out = Vec::new();
2230 let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
2231 let Some(iid) = stack.item_instance_id else {
2232 return;
2233 };
2234 if Some(iid) == grant_id {
2235 return;
2236 }
2237 if stack.props.get("enchantable").map(String::as_str) == Some("0") {
2238 return;
2239 }
2240 if !grant_target_matches_mode(stack, mode) {
2241 return;
2242 }
2243 if !grant_tags_match(stack, &grant_tags) {
2244 return;
2245 }
2246 let name = stack
2247 .display_name
2248 .clone()
2249 .unwrap_or_else(|| stack.template_id.clone());
2250 let bindings = if stack.status_bindings.is_empty() {
2251 String::new()
2252 } else {
2253 format!(
2254 " · {}",
2255 stack
2256 .status_bindings
2257 .iter()
2258 .map(|b| b.effect_id.as_str())
2259 .collect::<Vec<_>>()
2260 .join(", ")
2261 )
2262 };
2263 out.push(GrantTargetOption {
2264 label: format!("{where_label}: {name}{bindings}"),
2265 target_instance_id: iid,
2266 });
2267 };
2268 fn walk(
2269 stacks: &[flatland_protocol::ItemStack],
2270 where_label: &str,
2271 push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
2272 ) {
2273 for s in stacks {
2274 push(s, where_label);
2275 if !s.contents.is_empty() {
2276 let nested = format!(
2277 "{where_label}/{}",
2278 s.display_name
2279 .as_deref()
2280 .unwrap_or(s.template_id.as_str())
2281 );
2282 walk(&s.contents, &nested, push);
2283 }
2284 }
2285 }
2286 walk(&self.inventory_stacks, "Bag", &mut push);
2287 for (slot, stack) in &self.worn {
2288 push(stack, body_slot_label(*slot));
2289 let nest = format!(
2290 "{}/{}",
2291 body_slot_label(*slot),
2292 stack
2293 .display_name
2294 .as_deref()
2295 .unwrap_or(stack.template_id.as_str())
2296 );
2297 walk(&stack.contents, &nest, &mut push);
2298 }
2299 out
2300 }
2301
2302 pub fn item_base_mass(&self, template_id: &str) -> f32 {
2303 self.inventory_hints
2304 .get(template_id)
2305 .and_then(|h| h.base_mass)
2306 .unwrap_or(0.5)
2307 }
2308
2309 pub fn item_base_volume(&self, template_id: &str) -> f32 {
2310 self.inventory_hints
2311 .get(template_id)
2312 .and_then(|h| h.base_volume)
2313 .unwrap_or(1.0)
2314 }
2315
2316 pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
2317 let unit = stack
2318 .base_mass
2319 .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
2320 unit * stack.quantity as f32
2321 }
2322
2323 fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
2324 let unit = stack.base_volume.unwrap_or(1.0);
2325 unit * stack.quantity as f32
2326 + stack
2327 .contents
2328 .iter()
2329 .map(Self::stack_tree_volume)
2330 .sum::<f32>()
2331 }
2332
2333 fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
2334 contents.iter().map(Self::stack_tree_volume).sum()
2335 }
2336
2337 fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
2338 self.inventory_hints
2339 .get(template_id)
2340 .and_then(|h| h.capacity_volume)
2341 .filter(|c| *c > 0.0)
2342 }
2343
2344 fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
2345 stack
2346 .capacity_volume
2347 .filter(|c| *c > 0.0)
2348 .or_else(|| self.template_capacity_volume(&stack.template_id))
2349 }
2350
2351 pub fn container_volume_label(&self, row: &InventoryRow) -> String {
2353 let Some((used, cap)) = self.container_volume_stats(row) else {
2354 return String::new();
2355 };
2356 let free = (cap - used).max(0.0);
2357 format!(" vol {used:.0}/{cap:.0} ({free:.0} free)")
2358 }
2359
2360 fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
2361 if row.is_chest_shell {
2362 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
2363 return None;
2364 };
2365 let chest = self
2366 .placed_containers
2367 .iter()
2368 .find(|c| c.id == *container_id)?;
2369 let cap = self
2370 .stack_capacity_volume(&row.stack)
2371 .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
2372 let used = if chest.accessible {
2373 Self::contents_used_volume(&chest.contents)
2374 } else {
2375 0.0
2376 };
2377 return Some((used, cap));
2378 }
2379
2380 let cap = self.stack_capacity_volume(&row.stack)?;
2381 let used = Self::contents_used_volume(&row.stack.contents);
2382 Some((used, cap))
2383 }
2384
2385 pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
2386 if row.is_chest_shell {
2387 return true;
2388 }
2389 if row.is_equip_shell {
2390 return self.inventory_item_category(&row.stack.template_id) == Some("container");
2391 }
2392 self.inventory_item_category(&row.stack.template_id) == Some("container")
2393 || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
2394 }
2395
2396 fn container_stack_for(
2397 &self,
2398 location: &flatland_protocol::InventoryLocation,
2399 parent_instance_id: Option<uuid::Uuid>,
2400 ) -> Option<flatland_protocol::ItemStack> {
2401 match location {
2402 flatland_protocol::InventoryLocation::Root => {
2403 let pid = parent_instance_id?;
2404 self.find_stack_by_instance(&self.inventory_stacks, pid)
2405 }
2406 flatland_protocol::InventoryLocation::Worn { slot } => {
2407 let worn = self.worn.get(slot)?;
2408 if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
2409 Some(worn.clone())
2410 } else {
2411 self.find_stack_by_instance(&worn.contents, parent_instance_id?)
2412 }
2413 }
2414 flatland_protocol::InventoryLocation::Placed { container_id } => {
2415 let chest = self
2416 .placed_containers
2417 .iter()
2418 .find(|c| c.id == *container_id)?;
2419 if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
2420 Some(flatland_protocol::ItemStack {
2421 template_id: chest.template_id.clone(),
2422 quantity: 1,
2423 item_instance_id: chest.item_instance_id,
2424 props: Default::default(),
2425 status_bindings: Vec::new(),
2426 contents: chest.contents.clone(),
2427 display_name: Some(chest.display_name.clone()),
2428 category: Some("container".into()),
2429 capacity_volume: self
2430 .inventory_hints
2431 .get(&chest.template_id)
2432 .and_then(|h| h.capacity_volume),
2433 worker_lodging_capacity: chest.worker_lodging_capacity,
2434 ..Default::default()
2435 })
2436 } else {
2437 self.find_stack_by_instance(&chest.contents, parent_instance_id?)
2438 }
2439 }
2440 flatland_protocol::InventoryLocation::Keychain => None,
2441 flatland_protocol::InventoryLocation::WhisperPouch => None,
2442 }
2443 }
2444
2445 fn find_stack_by_instance(
2446 &self,
2447 stacks: &[flatland_protocol::ItemStack],
2448 instance_id: uuid::Uuid,
2449 ) -> Option<flatland_protocol::ItemStack> {
2450 for stack in stacks {
2451 if stack.item_instance_id == Some(instance_id) {
2452 return Some(stack.clone());
2453 }
2454 if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
2455 return Some(found);
2456 }
2457 }
2458 None
2459 }
2460
2461 pub fn max_movable_to(
2463 &self,
2464 template_id: &str,
2465 stack_qty: u32,
2466 from: &flatland_protocol::InventoryLocation,
2467 to: &flatland_protocol::InventoryLocation,
2468 parent_instance_id: Option<uuid::Uuid>,
2469 ) -> u32 {
2470 let unit_vol = self.item_base_volume(template_id);
2471 let unit_mass = self.item_base_mass(template_id);
2472 let mut limit = stack_qty;
2473
2474 if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2475 let cap = parent
2476 .capacity_volume
2477 .or_else(|| {
2478 self.inventory_hints
2479 .get(&parent.template_id)
2480 .and_then(|h| h.capacity_volume)
2481 })
2482 .unwrap_or(0.0);
2483 if cap > 0.0 && unit_vol > 0.0 {
2484 let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2485 limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2486 }
2487 }
2488
2489 let to_person = matches!(
2490 to,
2491 flatland_protocol::InventoryLocation::Root
2492 | flatland_protocol::InventoryLocation::Worn { .. }
2493 );
2494 let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2495 if to_person && from_placed && unit_mass > 0.0 {
2496 let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2497 if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2498 limit = 0;
2499 } else {
2500 limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2501 }
2502 }
2503
2504 limit.max(0).min(stack_qty)
2505 }
2506
2507 pub fn move_picker_max_at_selection(&self) -> u32 {
2508 let Some(picker) = &self.move_picker else {
2509 return 1;
2510 };
2511 let Some(opt) = picker.options.get(self.move_picker_index) else {
2512 return picker.stack_quantity;
2513 };
2514 match &opt.kind {
2515 MoveOptionKind::Cancel
2516 | MoveOptionKind::Drop
2517 | MoveOptionKind::Use
2518 | MoveOptionKind::GrantApply
2519 | MoveOptionKind::SellPlotToCrown { .. }
2520 | MoveOptionKind::PickupPlaced { .. }
2521 | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
2522 MoveOptionKind::Move {
2523 location,
2524 parent_instance_id,
2525 } => self.max_movable_to(
2526 &picker.template_id,
2527 picker.stack_quantity,
2528 &picker.from,
2529 location,
2530 *parent_instance_id,
2531 ),
2532 }
2533 }
2534
2535 pub fn clamp_move_picker_quantity(&mut self) {
2536 let max = self.move_picker_max_at_selection();
2537 if let Some(picker) = &mut self.move_picker {
2538 if max == 0 {
2539 picker.quantity = 1;
2540 } else {
2541 picker.quantity = picker.quantity.clamp(1, max);
2542 }
2543 }
2544 }
2545
2546 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2547 let max = self.move_picker_max_at_selection().max(1);
2548 if let Some(picker) = &mut self.move_picker {
2549 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2550 picker.quantity = next as u32;
2551 }
2552 }
2553
2554 pub fn move_picker_set_quantity_max(&mut self) {
2555 let max = self.move_picker_max_at_selection();
2556 if let Some(picker) = &mut self.move_picker {
2557 picker.quantity = if max == 0 {
2558 1
2559 } else {
2560 max.min(picker.stack_quantity)
2561 };
2562 }
2563 }
2564
2565 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2566 if let Some(picker) = &mut self.destroy_picker {
2567 let max = picker.stack_quantity.max(1);
2568 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2569 picker.quantity = next as u32;
2570 }
2571 }
2572
2573 pub fn destroy_picker_set_quantity_max(&mut self) {
2574 if let Some(picker) = &mut self.destroy_picker {
2575 picker.quantity = picker.stack_quantity.max(1);
2576 }
2577 }
2578
2579 pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2580 let have = self.inventory.get(template_id).copied().unwrap_or(0);
2581 (have, have >= need)
2582 }
2583
2584 pub fn currency_display(&self) -> String {
2585 crate::currency::currency_line(&self.inventory)
2586 }
2587
2588 pub fn in_shallow_water(&self) -> bool {
2590 let (px, py) = self.player_position();
2591 self.terrain_at(px, py)
2592 .is_some_and(|k| k == TerrainKindView::ShallowWater)
2593 }
2594
2595 pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
2596 self.terrain_zone_at(x, y).map(|z| z.kind)
2597 }
2598
2599 pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
2601 use std::cell::RefCell;
2602
2603 const CHUNK: i32 = 8;
2604 thread_local! {
2605 static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
2606 RefCell::new(None);
2607 }
2608
2609 let zones = &self.terrain_zones;
2610 if zones.is_empty() {
2611 return None;
2612 }
2613 if zones.len() <= 48 {
2614 return zones
2615 .iter()
2616 .enumerate()
2617 .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
2618 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
2619 .map(|(_, z)| z);
2620 }
2621
2622 let ptr = zones.as_ptr();
2623 let len = zones.len();
2624 INDEX.with(|cell| {
2625 let mut slot = cell.borrow_mut();
2626 let stale = match slot.as_ref() {
2627 Some((p, l, _)) => *p != ptr || *l != len,
2628 None => true,
2629 };
2630 if stale {
2631 let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
2632 std::collections::HashMap::new();
2633 for (zi, z) in zones.iter().enumerate() {
2634 let x0 = z.x0.min(z.x1).floor() as i32;
2635 let y0 = z.y0.min(z.y1).floor() as i32;
2636 let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
2637 let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
2638 let cx0 = x0.div_euclid(CHUNK);
2639 let cy0 = y0.div_euclid(CHUNK);
2640 let cx1 = x1.div_euclid(CHUNK);
2641 let cy1 = y1.div_euclid(CHUNK);
2642 for cy in cy0..=cy1 {
2643 for cx in cx0..=cx1 {
2644 chunks.entry((cx, cy)).or_default().push(zi);
2645 }
2646 }
2647 }
2648 *slot = Some((ptr, len, chunks));
2649 }
2650 let chunks = &slot.as_ref().expect("index").2;
2651 let cx = (x.floor() as i32).div_euclid(CHUNK);
2652 let cy = (y.floor() as i32).div_euclid(CHUNK);
2653 let mut best: Option<(usize, &TerrainZoneView)> = None;
2654 if let Some(list) = chunks.get(&(cx, cy)) {
2655 for &zi in list {
2656 let Some(z) = zones.get(zi) else { continue };
2657 if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
2658 continue;
2659 }
2660 best = match best {
2661 None => Some((zi, z)),
2662 Some((bi, bz)) => {
2663 if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
2664 Some((zi, z))
2665 } else {
2666 Some((bi, bz))
2667 }
2668 }
2669 };
2670 }
2671 }
2672 best.map(|(_, z)| z)
2673 })
2674 }
2675
2676 pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
2678 self.terrain_zone_at(x, y)
2679 .map(|z| z.elevation)
2680 .unwrap_or(0.0)
2681 }
2682
2683 pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
2685 const TOL: f32 = 0.35;
2686 let mut levels = vec![self.elevation_at(x, y)];
2687 for p in &self.z_platforms {
2688 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2689 levels.push(p.z);
2690 }
2691 }
2692 levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2693 levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
2694 levels
2695 }
2696
2697 pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
2698 const TOL: f32 = 0.35;
2699 self.walkable_levels_at(x, y)
2700 .iter()
2701 .any(|&l| (l - z).abs() <= TOL)
2702 }
2703
2704 pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
2705 let mut top = self.elevation_at(x, y);
2706 for p in &self.z_platforms {
2707 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2708 top = top.max(p.z);
2709 }
2710 }
2711 top
2712 }
2713
2714 pub fn effective_inside_building(&self) -> Option<String> {
2716 self.player_entity().and_then(|p| p.inside_building.clone())
2717 }
2718
2719 pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
2720 self.inventory_stacks = stacks.to_vec();
2721 self.inventory.clear();
2722 self.inventory_hints.clear();
2723 fn walk(
2724 stacks: &[flatland_protocol::ItemStack],
2725 inventory: &mut std::collections::HashMap<String, u32>,
2726 hints: &mut std::collections::HashMap<String, InventoryHint>,
2727 ) {
2728 for stack in stacks {
2729 *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
2730 if stack.display_name.is_some()
2731 || stack.category.is_some()
2732 || stack.base_mass.is_some()
2733 || stack.base_volume.is_some()
2734 {
2735 hints.insert(
2736 stack.template_id.clone(),
2737 InventoryHint {
2738 display_name: stack
2739 .display_name
2740 .clone()
2741 .unwrap_or_else(|| stack.template_id.clone()),
2742 category: stack.category.clone().unwrap_or_default(),
2743 base_mass: stack.base_mass,
2744 base_volume: stack.base_volume,
2745 capacity_volume: stack.capacity_volume,
2746 stackable: stack.stackable.unwrap_or(true),
2747 listable: stack.listable.unwrap_or_else(|| {
2748 category_default_listable(
2749 stack.category.as_deref().unwrap_or(""),
2750 )
2751 }),
2752 },
2753 );
2754 }
2755 walk(&stack.contents, inventory, hints);
2756 }
2757 }
2758 walk(stacks, &mut self.inventory, &mut self.inventory_hints);
2759 for item in self.worn.values() {
2761 walk(
2762 std::slice::from_ref(item),
2763 &mut self.inventory,
2764 &mut self.inventory_hints,
2765 );
2766 }
2767 }
2768
2769 pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
2772 let subtract_items =
2773 notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
2774 for stack in ¬ice.inventory_delta {
2775 if stack.quantity == 0 {
2776 continue;
2777 }
2778 if subtract_items {
2779 crate::currency::drain_template_stacks(
2780 &mut self.inventory_stacks,
2781 &stack.template_id,
2782 stack.quantity,
2783 );
2784 continue;
2785 }
2786 let stackable = self
2787 .inventory_hints
2788 .get(&stack.template_id)
2789 .map(|h| h.stackable)
2790 .or(stack.stackable)
2791 .unwrap_or(true);
2792 if stackable {
2793 if let Some(existing) = self
2794 .inventory_stacks
2795 .iter_mut()
2796 .find(|s| s.template_id == stack.template_id)
2797 {
2798 existing.quantity = existing.quantity.saturating_add(stack.quantity);
2799 if stack.display_name.is_some() {
2800 existing.display_name = stack.display_name.clone();
2801 }
2802 if stack.category.is_some() {
2803 existing.category = stack.category.clone();
2804 }
2805 continue;
2806 }
2807 }
2808 self.inventory_stacks.push(stack.clone());
2809 }
2810 if notice.coins_delta != 0 {
2811 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
2812 }
2813 if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
2814 let stacks = self.inventory_stacks.clone();
2815 self.sync_inventory_from_stacks(&stacks);
2816 }
2817 self.record_shop_trade_notice(notice);
2818 }
2819
2820 pub fn worn_rows(&self) -> Vec<InventoryRow> {
2825 let mut rows = Vec::new();
2826 for (slot, item) in &self.worn {
2827 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2828 rows.push(InventoryRow {
2829 depth: 0,
2830 stack: item.clone(),
2831 from: from.clone(),
2832 from_parent_instance_id: None,
2833 is_equip_shell: true,
2834 is_chest_shell: false,
2835 section: InventorySection::Worn,
2836 });
2837 for child in &item.contents {
2838 push_inventory_rows(
2839 &mut rows,
2840 1,
2841 child,
2842 &from,
2843 item.item_instance_id,
2844 InventorySection::Worn,
2845 );
2846 }
2847 }
2848 rows
2849 }
2850
2851 pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
2853 self.inventory_stacks
2854 .iter()
2855 .filter_map(|stack| {
2856 let item_instance_id = stack.item_instance_id?;
2857 let label = stack
2858 .display_name
2859 .clone()
2860 .unwrap_or_else(|| stack.template_id.clone());
2861 let label = if stack.quantity > 1 {
2862 format!("{label} ×{}", stack.quantity)
2863 } else {
2864 label
2865 };
2866 Some(WorkerGiveOption {
2867 item_instance_id,
2868 label,
2869 quantity: stack.quantity,
2870 template_id: stack.template_id.clone(),
2871 })
2872 })
2873 .collect()
2874 }
2875
2876 pub fn teachable_blueprint_options(
2878 &self,
2879 worker: &flatland_protocol::HiredWorkerView,
2880 ) -> Vec<WorkerTeachOption> {
2881 let copper = crate::currency::copper_from_counts(&self.inventory);
2882 let mut options: Vec<WorkerTeachOption> = self
2883 .blueprints
2884 .iter()
2885 .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
2886 .map(|bp| {
2887 let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
2888 let cost = bp.worker_train_copper;
2889 WorkerTeachOption {
2890 blueprint_id: bp.id.clone(),
2891 label: if bp.label.is_empty() {
2892 bp.id.clone()
2893 } else {
2894 bp.label.clone()
2895 },
2896 cost_copper: cost,
2897 min_level,
2898 worker_level: worker.level,
2899 can_afford: copper >= cost,
2900 level_ok: worker.level >= min_level,
2901 }
2902 })
2903 .collect();
2904 options.sort_by(|a, b| a.label.cmp(&b.label));
2905 options
2906 }
2907
2908 pub fn person_rows(&self) -> Vec<InventoryRow> {
2911 self.person_rows_filtered("")
2912 }
2913
2914 pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2915 let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
2916 roots.sort_by(|a, b| {
2917 let ca = a
2918 .category
2919 .as_deref()
2920 .or_else(|| self.inventory_item_category(&a.template_id))
2921 .unwrap_or("");
2922 let cb = b
2923 .category
2924 .as_deref()
2925 .or_else(|| self.inventory_item_category(&b.template_id))
2926 .unwrap_or("");
2927 let ga = inventory_category_group(ca).1;
2928 let gb = inventory_category_group(cb).1;
2929 ga.cmp(&gb).then_with(|| {
2930 let na = a
2931 .display_name
2932 .as_deref()
2933 .unwrap_or(a.template_id.as_str());
2934 let nb = b
2935 .display_name
2936 .as_deref()
2937 .unwrap_or(b.template_id.as_str());
2938 na.cmp(nb)
2939 })
2940 });
2941 let mut rows = Vec::new();
2942 for stack in roots {
2943 push_inventory_rows_filtered(
2944 &mut rows,
2945 0,
2946 stack,
2947 &flatland_protocol::InventoryLocation::Root,
2948 None,
2949 InventorySection::Person,
2950 filter,
2951 );
2952 }
2953 rows
2954 }
2955
2956 pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2957 if filter.is_empty() {
2958 return self.worn_rows();
2959 }
2960 let mut rows = Vec::new();
2961 for (slot, item) in &self.worn {
2962 if !stack_matches_filter(item, filter) {
2963 continue;
2964 }
2965 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2966 let self_hit = {
2967 let f = filter.to_ascii_lowercase();
2968 let name = item
2969 .display_name
2970 .as_deref()
2971 .unwrap_or("")
2972 .to_ascii_lowercase();
2973 let tid = item.template_id.to_ascii_lowercase();
2974 name.contains(&f) || tid.contains(&f)
2975 };
2976 rows.push(InventoryRow {
2977 depth: 0,
2978 stack: item.clone(),
2979 from: from.clone(),
2980 from_parent_instance_id: None,
2981 is_equip_shell: true,
2982 is_chest_shell: false,
2983 section: InventorySection::Worn,
2984 });
2985 for child in &item.contents {
2986 if self_hit || stack_matches_filter(child, filter) {
2987 push_inventory_rows_filtered(
2988 &mut rows,
2989 1,
2990 child,
2991 &from,
2992 item.item_instance_id,
2993 InventorySection::Worn,
2994 if self_hit { "" } else { filter },
2995 );
2996 }
2997 }
2998 }
2999 rows
3000 }
3001
3002 pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
3004 let mut rows = self.worn_rows();
3005 rows.extend(self.person_rows());
3006 rows.into_iter().map(|r| (r.depth, r.stack)).collect()
3007 }
3008
3009 pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
3013 let (px, py) = self.player_position();
3014 let mut list: Vec<NearbyContainer> = self
3015 .placed_containers
3016 .iter()
3017 .filter_map(|c| {
3018 let distance_m = (c.x - px).hypot(c.y - py);
3019 if distance_m > CONTAINER_RANGE_M {
3020 return None;
3021 }
3022 let mut rows = Vec::new();
3023 let from = flatland_protocol::InventoryLocation::Placed {
3024 container_id: c.id.clone(),
3025 };
3026 rows.push(InventoryRow {
3027 depth: 0,
3028 stack: flatland_protocol::ItemStack {
3029 template_id: c.template_id.clone(),
3030 quantity: 1,
3031 item_instance_id: c.item_instance_id,
3032 props: Default::default(),
3033 status_bindings: Vec::new(),
3034 contents: Vec::new(),
3035 display_name: Some(c.display_name.clone()),
3036 category: Some("container".into()),
3037 capacity_volume: c.capacity_volume,
3038 worker_lodging_capacity: c.worker_lodging_capacity,
3039 ..Default::default()
3040 },
3041 from: from.clone(),
3042 from_parent_instance_id: None,
3043 is_equip_shell: false,
3044 is_chest_shell: true,
3045 section: InventorySection::Nearby,
3046 });
3047 if c.accessible {
3048 for child in &c.contents {
3049 push_inventory_rows(
3050 &mut rows,
3051 1,
3052 child,
3053 &from,
3054 c.item_instance_id,
3055 InventorySection::Nearby,
3056 );
3057 }
3058 }
3059 Some(NearbyContainer {
3060 view: c.clone(),
3061 distance_m,
3062 rows,
3063 })
3064 })
3065 .collect();
3066 list.sort_by(|a, b| {
3067 a.distance_m
3068 .partial_cmp(&b.distance_m)
3069 .unwrap_or(std::cmp::Ordering::Equal)
3070 });
3071 list
3072 }
3073
3074 pub fn nearest_placed_container(
3076 &self,
3077 max_dist: f32,
3078 ) -> Option<flatland_protocol::PlacedContainerView> {
3079 let (px, py) = self.player_position();
3080 self.placed_containers
3081 .iter()
3082 .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
3083 .min_by(|a, b| {
3084 let da = (a.x - px).hypot(a.y - py);
3085 let db = (b.x - px).hypot(b.y - py);
3086 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3087 })
3088 .cloned()
3089 }
3090
3091 pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
3094 let filter = self.inventory_filter.as_str();
3095 match self.inventory_tab {
3096 InventoryTab::OnPerson => {
3097 let mut rows = self.worn_rows_filtered(filter);
3098 rows.extend(self.person_rows_filtered(filter));
3099 rows
3100 }
3101 InventoryTab::Nearby => {
3102 let mut rows = Vec::new();
3103 for nc in self.nearby_containers() {
3104 if filter.is_empty() {
3105 rows.extend(nc.rows);
3106 continue;
3107 }
3108 let shell = nc.rows.first().cloned();
3109 let contents: Vec<_> = nc
3110 .rows
3111 .iter()
3112 .skip(1)
3113 .filter(|r| stack_matches_filter(&r.stack, filter))
3114 .cloned()
3115 .collect();
3116 let shell_hit = shell
3117 .as_ref()
3118 .map(|s| stack_matches_filter(&s.stack, filter))
3119 .unwrap_or(false);
3120 if shell_hit || !contents.is_empty() {
3121 if let Some(s) = shell {
3122 rows.push(s);
3123 }
3124 if shell_hit {
3125 rows.extend(nc.rows.into_iter().skip(1));
3126 } else {
3127 rows.extend(contents);
3128 }
3129 }
3130 }
3131 rows
3132 }
3133 }
3134 }
3135
3136 pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
3137 self.inventory_selectable_rows()
3138 .into_iter()
3139 .nth(self.inventory_menu_index)
3140 }
3141
3142 fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
3143 let cat = self
3144 .inventory_item_category(&row.stack.template_id)
3145 .unwrap_or("");
3146 if cat == "key" {
3147 self.key_inventory_label(&row.stack)
3148 } else {
3149 row.stack
3150 .display_name
3151 .clone()
3152 .unwrap_or_else(|| row.stack.template_id.clone())
3153 }
3154 }
3155
3156 fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
3158 let bindings = format_status_bindings_suffix(
3159 &row.stack.status_bindings,
3160 self.tick,
3161 DEFAULT_TICK_HZ,
3162 );
3163 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3164 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3165 let mode = Self::grant_mode(&row.stack);
3166 format!(" [grant {effect} · {mode} — e apply]")
3167 } else {
3168 String::new()
3169 };
3170 let qty = if row.stack.quantity > 1 {
3171 format!(" ×{}", row.stack.quantity)
3172 } else {
3173 String::new()
3174 };
3175 let worn_slot = if row.is_equip_shell {
3176 match row.from {
3177 flatland_protocol::InventoryLocation::Worn { slot } => {
3178 format!(" ({})", body_slot_label(slot))
3179 }
3180 _ => String::new(),
3181 }
3182 } else {
3183 String::new()
3184 };
3185 format!("{grant_hint}{bindings}{qty}{worn_slot}")
3186 }
3187
3188 fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
3189 (
3190 row.stack.template_id.clone(),
3191 self.inventory_row_base_label(row),
3192 self.inventory_row_visible_mod_signature(row),
3193 )
3194 }
3195
3196 fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
3198 let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
3199 for row in self.inventory_selectable_rows() {
3200 if row.stack.item_instance_id.is_none() {
3201 continue;
3202 }
3203 let key = self.inventory_row_instance_identity_key(&row);
3204 *counts.entry(key).or_default() += 1;
3205 }
3206 counts
3207 .into_iter()
3208 .filter(|(_, n)| *n > 1)
3209 .map(|(k, _)| k)
3210 .collect()
3211 }
3212
3213 fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
3214 let hex: String = id
3215 .as_simple()
3216 .to_string()
3217 .chars()
3218 .filter(|c| c.is_ascii_hexdigit())
3219 .collect();
3220 let short = if hex.len() >= 4 {
3221 &hex[hex.len() - 4..]
3222 } else {
3223 hex.as_str()
3224 };
3225 format!("Instance {id} (#{short})")
3226 }
3227
3228 pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
3230 let cat = self
3231 .inventory_item_category(&row.stack.template_id)
3232 .unwrap_or("");
3233 let label = self.inventory_row_base_label(row);
3234 let hint: String = if row.is_equip_shell {
3235 " [worn — Enter to unequip]".into()
3236 } else if row.is_chest_shell {
3237 let (locked, lodging_note) = match &row.from {
3238 flatland_protocol::InventoryLocation::Placed { container_id } => {
3239 let locked = self
3240 .placed_containers
3241 .iter()
3242 .find(|c| c.id == *container_id)
3243 .map(|c| c.locked)
3244 .unwrap_or(false);
3245 let lodging_note = self
3246 .lodging_occupancy_label(container_id)
3247 .map(|who| format!(" [lodging: {who}]"))
3248 .unwrap_or_default();
3249 (locked, lodging_note)
3250 }
3251 _ => (false, String::new()),
3252 };
3253 if locked {
3254 format!(" [locked — Enter pick up · l unlock]{lodging_note}")
3255 } else {
3256 format!(" [Enter pick up · l lock]{lodging_note}")
3257 }
3258 } else if cat == "key" {
3259 self.key_inventory_hint(&row.stack)
3260 } else {
3261 match cat {
3262 "weapon" => " [weapon]".into(),
3263 "container" => " [bag/chest/belt]".into(),
3264 "lodging" => " [worker lodging]".into(),
3265 "armor" => " [armor]".into(),
3266 _ => String::new(),
3267 }
3268 };
3269 let qty = if row.stack.quantity > 1 {
3270 format!(" ×{}", row.stack.quantity)
3271 } else {
3272 String::new()
3273 };
3274 let bindings = format_status_bindings_suffix(
3275 &row.stack.status_bindings,
3276 self.tick,
3277 DEFAULT_TICK_HZ,
3278 );
3279 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3280 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3281 let mode = Self::grant_mode(&row.stack);
3282 format!(" [grant {effect} · {mode} — e apply]")
3283 } else {
3284 String::new()
3285 };
3286 let mass = self.stack_mass(&row.stack);
3287 let mass_kg = (mass >= 0.05).then_some(mass);
3288 let mass_str = mass_kg
3289 .map(|m| format!(" {m:.1} kg"))
3290 .unwrap_or_default();
3291 let volume = self.container_volume_stats(row);
3292 let vol_str = self.container_volume_label(row);
3293
3294 let mut title = label.clone();
3295 title.push_str(&qty);
3296 if row.is_equip_shell {
3297 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3298 title.push_str(&format!(" ({})", body_slot_label(slot)));
3299 }
3300 }
3301
3302 InventoryRowView {
3303 depth: row.depth,
3304 text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
3305 title: format!("{title}{grant_hint}{bindings}"),
3306 mass_kg,
3307 volume,
3308 instance_tooltip: None,
3309 }
3310 }
3311
3312 fn push_browser_item(
3313 &self,
3314 lines: &mut Vec<InventoryBrowserLine>,
3315 row: &InventoryRow,
3316 global_idx: &mut usize,
3317 target: usize,
3318 highlight: bool,
3319 ambiguous_instance_keys: &HashSet<(String, String, String)>,
3320 ) {
3321 let mut view = self.format_inventory_row(row);
3322 if let Some(id) = row.stack.item_instance_id {
3323 let key = self.inventory_row_instance_identity_key(row);
3324 if ambiguous_instance_keys.contains(&key) {
3325 view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
3326 }
3327 }
3328 lines.push(InventoryBrowserLine::Item {
3329 selectable_index: *global_idx,
3330 selected: highlight && *global_idx == target,
3331 depth: view.depth,
3332 text: view.text,
3333 title: view.title,
3334 mass_kg: view.mass_kg,
3335 volume: view.volume,
3336 instance_tooltip: view.instance_tooltip,
3337 });
3338 *global_idx += 1;
3339 }
3340
3341 pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
3344 let mut lines = Vec::new();
3345 let target = self.inventory_menu_index;
3346 let highlight = !self.show_move_picker && !self.show_grant_picker;
3347 let filter = self.inventory_filter.as_str();
3348 let mut global_idx = 0usize;
3349 let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
3350
3351 match self.inventory_tab {
3352 InventoryTab::OnPerson => {
3353 lines.push(InventoryBrowserLine::Section("— Worn —".into()));
3354 let worn = self.worn_rows_filtered(filter);
3355 if worn.is_empty() {
3356 lines.push(InventoryBrowserLine::Hint(
3357 " (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
3358 ));
3359 } else {
3360 for row in &worn {
3361 if row.is_equip_shell {
3362 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3363 lines.push(InventoryBrowserLine::SlotLabel(format!(
3364 " {}:",
3365 body_slot_label(slot)
3366 )));
3367 }
3368 }
3369 self.push_browser_item(
3370 &mut lines,
3371 row,
3372 &mut global_idx,
3373 target,
3374 highlight,
3375 &ambiguous_instance_keys,
3376 );
3377 }
3378 }
3379
3380 lines.push(InventoryBrowserLine::Blank);
3381 lines.push(InventoryBrowserLine::Section(
3382 "— On you (loose, not worn) —".into(),
3383 ));
3384 let person = self.person_rows_filtered(filter);
3385 if person.is_empty() {
3386 lines.push(InventoryBrowserLine::Hint(" (empty)".into()));
3387 } else {
3388 let mut last_group: Option<&'static str> = None;
3389 for row in &person {
3390 if row.depth == 0 {
3391 let cat = row
3392 .stack
3393 .category
3394 .as_deref()
3395 .or_else(|| self.inventory_item_category(&row.stack.template_id))
3396 .unwrap_or("");
3397 let (group, _) = inventory_category_group(cat);
3398 if last_group != Some(group) {
3399 lines.push(InventoryBrowserLine::SlotLabel(format!(
3400 " {group}"
3401 )));
3402 last_group = Some(group);
3403 }
3404 }
3405 self.push_browser_item(
3406 &mut lines,
3407 row,
3408 &mut global_idx,
3409 target,
3410 highlight,
3411 &ambiguous_instance_keys,
3412 );
3413 }
3414 }
3415 }
3416 InventoryTab::Nearby => {
3417 let nearby = self.nearby_containers();
3418 if nearby.is_empty() {
3419 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3420 lines.push(InventoryBrowserLine::Hint(
3421 " (none within reach — walk up to a chest)".into(),
3422 ));
3423 lines.push(InventoryBrowserLine::Hint(
3424 " Select an on-person item, then m / Enter → move into chest.".into(),
3425 ));
3426 } else {
3427 let mut any_visible = false;
3428 for nc in &nearby {
3429 let shell = nc.rows.first();
3430 let contents: Vec<&InventoryRow> = if filter.is_empty() {
3431 nc.rows.iter().skip(1).collect()
3432 } else {
3433 let shell_hit = shell
3434 .map(|s| {
3435 let f = filter.to_ascii_lowercase();
3436 let name = s
3437 .stack
3438 .display_name
3439 .as_deref()
3440 .unwrap_or("")
3441 .to_ascii_lowercase();
3442 let tid = s.stack.template_id.to_ascii_lowercase();
3443 name.contains(&f) || tid.contains(&f)
3444 })
3445 .unwrap_or(false);
3446 if shell_hit {
3447 nc.rows.iter().skip(1).collect()
3448 } else {
3449 nc.rows
3450 .iter()
3451 .skip(1)
3452 .filter(|r| stack_matches_filter(&r.stack, filter))
3453 .collect()
3454 }
3455 };
3456 let shell_visible = filter.is_empty()
3457 || shell
3458 .map(|s| stack_matches_filter(&s.stack, filter))
3459 .unwrap_or(false)
3460 || !contents.is_empty();
3461 if !shell_visible && shell.is_some() {
3462 continue;
3463 }
3464 any_visible = true;
3465 lines.push(InventoryBrowserLine::Blank);
3466 let lock_note = if nc.view.locked && nc.view.accessible {
3467 " unlocked with your key"
3468 } else if nc.view.locked {
3469 " locked"
3470 } else {
3471 ""
3472 };
3473 lines.push(InventoryBrowserLine::Section(format!(
3474 "— {} ({:.0}m away){lock_note} —",
3475 nc.view.display_name, nc.distance_m
3476 )));
3477 if !nc.view.accessible {
3478 lines.push(InventoryBrowserLine::Hint(
3479 " locked — need the matching key (l to try)".into(),
3480 ));
3481 } else if nc.rows.is_empty() {
3482 lines.push(InventoryBrowserLine::Hint(
3483 " (empty — switch to On person, select an item, m to move in)"
3484 .into(),
3485 ));
3486 } else if let Some(shell_row) = shell {
3487 self.push_browser_item(
3488 &mut lines,
3489 shell_row,
3490 &mut global_idx,
3491 target,
3492 highlight,
3493 &ambiguous_instance_keys,
3494 );
3495 for row in contents {
3496 self.push_browser_item(
3497 &mut lines,
3498 row,
3499 &mut global_idx,
3500 target,
3501 highlight,
3502 &ambiguous_instance_keys,
3503 );
3504 }
3505 }
3506 }
3507 if !any_visible {
3508 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3509 lines.push(InventoryBrowserLine::Hint(
3510 " (no matching items — clear filter with Esc)".into(),
3511 ));
3512 }
3513 }
3514 }
3515 }
3516 lines
3517 }
3518
3519 pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
3521 let mut opts = Vec::new();
3522 opts.push(MoveOption {
3523 label: "Relocate…".into(),
3524 kind: MoveOptionKind::RelocatePlaced {
3525 container_id: container_id.to_string(),
3526 },
3527 });
3528 opts.push(MoveOption {
3529 label: "On your person (loose)".into(),
3530 kind: MoveOptionKind::PickupPlaced {
3531 container_id: container_id.to_string(),
3532 nest_location: flatland_protocol::InventoryLocation::Root,
3533 nest_parent_instance_id: None,
3534 },
3535 });
3536 for (slot, item) in &self.worn {
3537 if item.category.as_deref() != Some("container") {
3538 continue;
3539 }
3540 if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
3541 continue;
3542 }
3543 let Some(parent_id) = item.item_instance_id else {
3544 continue;
3545 };
3546 let shell_name = item
3547 .display_name
3548 .clone()
3549 .unwrap_or_else(|| item.template_id.clone());
3550 opts.push(MoveOption {
3551 label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
3552 kind: MoveOptionKind::PickupPlaced {
3553 container_id: container_id.to_string(),
3554 nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
3555 nest_parent_instance_id: Some(parent_id),
3556 },
3557 });
3558 Self::append_chest_pickup_nested(
3560 &mut opts,
3561 container_id,
3562 flatland_protocol::InventoryLocation::Worn { slot: *slot },
3563 item,
3564 &format!("in {shell_name}"),
3565 );
3566 }
3567 opts.push(MoveOption {
3568 label: "Cancel".into(),
3569 kind: MoveOptionKind::Cancel,
3570 });
3571 opts
3572 }
3573
3574 fn append_chest_pickup_nested(
3575 opts: &mut Vec<MoveOption>,
3576 container_id: &str,
3577 location: flatland_protocol::InventoryLocation,
3578 parent: &flatland_protocol::ItemStack,
3579 context: &str,
3580 ) {
3581 for child in &parent.contents {
3582 if child.category.as_deref() != Some("container") {
3583 continue;
3584 }
3585 if !Self::is_volume_container_stack(child) {
3586 continue;
3587 }
3588 if child.world_placeable == Some(true) {
3590 continue;
3591 }
3592 let Some(child_id) = child.item_instance_id else {
3593 continue;
3594 };
3595 let name = child
3596 .display_name
3597 .clone()
3598 .unwrap_or_else(|| child.template_id.clone());
3599 opts.push(MoveOption {
3600 label: format!("{name} ({context})"),
3601 kind: MoveOptionKind::PickupPlaced {
3602 container_id: container_id.to_string(),
3603 nest_location: location.clone(),
3604 nest_parent_instance_id: Some(child_id),
3605 },
3606 });
3607 Self::append_chest_pickup_nested(
3608 opts,
3609 container_id,
3610 location.clone(),
3611 child,
3612 &format!("in {name}"),
3613 );
3614 }
3615 }
3616
3617 pub fn move_destinations_for(
3619 &self,
3620 from: &flatland_protocol::InventoryLocation,
3621 from_parent_instance_id: Option<uuid::Uuid>,
3622 moving_instance_id: Option<uuid::Uuid>,
3623 moving_template_id: &str,
3624 ) -> Vec<MoveOption> {
3625 let mut opts = Vec::new();
3626 if *from != flatland_protocol::InventoryLocation::Root {
3627 opts.push(MoveOption {
3628 label: "On your person (loose)".into(),
3629 kind: MoveOptionKind::Move {
3630 location: flatland_protocol::InventoryLocation::Root,
3631 parent_instance_id: None,
3632 },
3633 });
3634 }
3635 for (slot, item) in &self.worn {
3636 if item.category.as_deref() != Some("container") {
3637 continue;
3638 }
3639 let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3640 let shell_name = item
3641 .display_name
3642 .clone()
3643 .unwrap_or_else(|| item.template_id.clone());
3644
3645 if *slot != BodySlot::Waist
3647 && item.item_instance_id != moving_instance_id
3648 && Self::is_volume_container_stack(item)
3649 {
3650 Self::push_move_destination(
3651 &mut opts,
3652 format!("{shell_name} (worn {})", body_slot_label(*slot)),
3653 location.clone(),
3654 item.item_instance_id,
3655 from,
3656 from_parent_instance_id,
3657 );
3658 }
3659
3660 if *slot == BodySlot::Waist
3662 && Self::attaches_to_belt_loop(moving_template_id)
3663 && item.item_instance_id != moving_instance_id
3664 {
3665 Self::push_move_destination(
3666 &mut opts,
3667 format!("{shell_name} (belt loop)"),
3668 location.clone(),
3669 item.item_instance_id,
3670 from,
3671 from_parent_instance_id,
3672 );
3673 }
3674
3675 let context = if *slot == BodySlot::Waist {
3676 format!("on {shell_name}")
3677 } else {
3678 format!("in {shell_name}")
3679 };
3680 Self::append_nested_container_destinations(
3681 &mut opts,
3682 location,
3683 item,
3684 &context,
3685 from,
3686 from_parent_instance_id,
3687 moving_instance_id,
3688 );
3689 }
3690 for nc in self.nearby_containers() {
3691 if !nc.view.accessible {
3692 continue;
3693 }
3694 let location = flatland_protocol::InventoryLocation::Placed {
3695 container_id: nc.view.id.clone(),
3696 };
3697 Self::push_move_destination(
3698 &mut opts,
3699 format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
3700 location,
3701 nc.view.item_instance_id,
3702 from,
3703 from_parent_instance_id,
3704 );
3705 }
3706 let allow_drop = moving_instance_id
3707 .and_then(|id| self.stack_for_instance(id))
3708 .map(|stack| {
3709 !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
3710 })
3711 .unwrap_or(
3712 moving_template_id != KEY_TEMPLATE && moving_template_id != PROPERTY_DEED_TEMPLATE,
3713 );
3714 if allow_drop {
3715 opts.push(MoveOption {
3716 label: "Drop on the ground".into(),
3717 kind: MoveOptionKind::Drop,
3718 });
3719 }
3720 opts.push(MoveOption {
3721 label: "Cancel".into(),
3722 kind: MoveOptionKind::Cancel,
3723 });
3724 opts
3725 }
3726
3727 fn is_same_container_dest(
3728 dest_location: &flatland_protocol::InventoryLocation,
3729 dest_parent: Option<uuid::Uuid>,
3730 from: &flatland_protocol::InventoryLocation,
3731 from_parent: Option<uuid::Uuid>,
3732 ) -> bool {
3733 dest_location == from && dest_parent == from_parent
3734 }
3735
3736 fn push_move_destination(
3737 opts: &mut Vec<MoveOption>,
3738 label: String,
3739 location: flatland_protocol::InventoryLocation,
3740 parent_instance_id: Option<uuid::Uuid>,
3741 from: &flatland_protocol::InventoryLocation,
3742 from_parent_instance_id: Option<uuid::Uuid>,
3743 ) {
3744 if Self::is_same_container_dest(
3745 &location,
3746 parent_instance_id,
3747 from,
3748 from_parent_instance_id,
3749 ) {
3750 return;
3751 }
3752 opts.push(MoveOption {
3753 label,
3754 kind: MoveOptionKind::Move {
3755 location,
3756 parent_instance_id,
3757 },
3758 });
3759 }
3760
3761 fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
3762 stack.capacity_volume.is_some_and(|c| c > 0.0)
3763 }
3764
3765 fn attaches_to_belt_loop(template_id: &str) -> bool {
3766 matches!(template_id, "leather_pouch" | "dimensional_pouch")
3767 }
3768
3769 fn append_nested_container_destinations(
3770 opts: &mut Vec<MoveOption>,
3771 location: flatland_protocol::InventoryLocation,
3772 container: &flatland_protocol::ItemStack,
3773 context: &str,
3774 from: &flatland_protocol::InventoryLocation,
3775 from_parent_instance_id: Option<uuid::Uuid>,
3776 moving_instance_id: Option<uuid::Uuid>,
3777 ) {
3778 for child in &container.contents {
3779 if Self::is_volume_container_stack(child)
3780 && child.item_instance_id != moving_instance_id
3781 {
3782 let name = child
3783 .display_name
3784 .clone()
3785 .unwrap_or_else(|| child.template_id.clone());
3786 Self::push_move_destination(
3787 opts,
3788 format!("{name} ({context})"),
3789 location.clone(),
3790 child.item_instance_id,
3791 from,
3792 from_parent_instance_id,
3793 );
3794 }
3795 let nested_context = format!(
3796 "in {}",
3797 child.display_name.as_deref().unwrap_or(&child.template_id)
3798 );
3799 Self::append_nested_container_destinations(
3800 opts,
3801 location.clone(),
3802 child,
3803 &nested_context,
3804 from,
3805 from_parent_instance_id,
3806 moving_instance_id,
3807 );
3808 }
3809 }
3810
3811 fn clamp_inventory_indices(&mut self) {
3812 let n = self.inventory_selectable_rows().len();
3813 self.inventory_menu_index = if n == 0 {
3814 0
3815 } else {
3816 self.inventory_menu_index.min(n - 1)
3817 };
3818 if let Some(picker) = &self.move_picker {
3819 let pn = picker.options.len();
3820 self.move_picker_index = if pn == 0 {
3821 0
3822 } else {
3823 self.move_picker_index.min(pn - 1)
3824 };
3825 }
3826 }
3827
3828 fn sync_interior_map_context(&mut self) {
3830 if self.effective_inside_building().is_none() {
3831 self.interior_map = None;
3832 }
3833 self.sync_interior_z_bands();
3834 }
3835
3836 fn sync_interior_z_bands(&mut self) {
3838 if self.effective_inside_building().is_some() {
3839 if let Some(map) = &self.interior_map {
3840 if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
3841 self.z_platforms = map.z_platforms.clone();
3842 self.z_transitions = map.z_transitions.clone();
3843 }
3844 }
3845 }
3846 }
3847
3848 fn apply_snapshot_fields(
3849 &mut self,
3850 snapshot: &flatland_protocol::Snapshot,
3851 entity_id: EntityId,
3852 ) {
3853 self.tick = snapshot.tick;
3854 self.chunk_rev = snapshot.chunk_rev;
3855 self.content_rev = snapshot.content_rev;
3856 self.publish_rev = snapshot.publish_rev;
3857 self.resource_nodes = snapshot.resource_nodes.clone();
3858 self.ground_drops = snapshot.ground_drops.clone();
3859 self.placed_containers = snapshot.placed_containers.clone();
3860 self.world_x0 = snapshot.world_x0;
3861 self.world_y0 = snapshot.world_y0;
3862 self.world_width_m = snapshot.world_width_m;
3863 self.world_height_m = snapshot.world_height_m;
3864 self.world_clock = snapshot.world_clock;
3865 self.terrain_zones = snapshot.terrain_zones.clone();
3866 self.z_platforms = snapshot.z_platforms.clone();
3867 self.z_transitions = snapshot.z_transitions.clone();
3868 self.buildings = snapshot.buildings.clone();
3869 self.doors = snapshot.doors.clone();
3870 self.interior_map = snapshot.interior_map.clone();
3871 self.npcs = snapshot.npcs.clone();
3872 self.blueprints = snapshot.blueprints.clone();
3873 self.sync_inventory_from_stacks(&snapshot.inventory);
3874 self.player = snapshot
3875 .entities
3876 .iter()
3877 .find(|e| e.id == entity_id)
3878 .cloned();
3879 self.entities = snapshot.entities.clone();
3880 self.quest_log = snapshot.quest_log.clone();
3881 self.apply_hired_workers(snapshot.hired_workers.clone());
3882 self.interactables = snapshot.interactables.clone();
3883 self.ledger = snapshot.ledger.clone();
3884 self.career = snapshot.career.clone();
3885 self.combat_fx = snapshot.combat_fx.clone();
3886 self.property_zones = snapshot.property_zones.clone();
3887 self.tax_zones = snapshot.tax_zones.clone();
3888 self.growth_zones = snapshot.growth_zones.clone();
3889 self.biome_zones = snapshot.biome_zones.clone();
3890 self.property_plots = snapshot.property_plots.clone();
3891 self.property_plot_settings = snapshot.property_plot_settings.clone();
3892 self.sync_interior_map_context();
3893 self.refresh_whisper_range();
3894 }
3895
3896 fn refresh_inventory_ui(&mut self) {
3900 if let Some(picker) = &self.move_picker {
3901 let instance_id = picker.item_instance_id;
3902 let still_exists = self
3903 .inventory_selectable_rows()
3904 .iter()
3905 .any(|r| r.stack.item_instance_id == Some(instance_id));
3906 if !still_exists {
3907 self.move_picker = None;
3908 self.show_move_picker = false;
3909 }
3910 }
3911 if let Some(picker) = &self.destroy_picker {
3912 let instance_id = picker.item_instance_id;
3913 let still_exists = self
3914 .inventory_selectable_rows()
3915 .iter()
3916 .any(|r| r.stack.item_instance_id == Some(instance_id));
3917 if !still_exists {
3918 self.destroy_picker = None;
3919 self.show_destroy_picker = false;
3920 self.destroy_confirm_pending = false;
3921 }
3922 }
3923 self.clamp_inventory_indices();
3924 }
3925
3926 fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
3932 let selected_id = self
3933 .hired_workers
3934 .get(self.workers_menu_index)
3935 .map(|w| w.instance_id.clone());
3936 workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
3937 let now = Instant::now();
3938 for w in &workers {
3939 let prev_err = self
3940 .hired_workers
3941 .iter()
3942 .find(|p| p.instance_id == w.instance_id)
3943 .and_then(|p| p.last_error.as_deref());
3944 let new_err = w.last_error.as_deref();
3945 if new_err != prev_err {
3946 if let Some(err) = new_err {
3947 if !worker_error_is_transient(err) {
3948 self.push_log(format!("Worker {}: {err}", w.label));
3949 }
3950 }
3951 }
3952 }
3953 let mut next_display = BTreeMap::new();
3954 let mut next_errors = BTreeMap::new();
3955 for w in &workers {
3956 let mut sticky = self
3957 .worker_step_display
3958 .remove(&w.instance_id)
3959 .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
3960 sticky.observe(&w.step_label, now);
3961 next_display.insert(w.instance_id.clone(), sticky);
3962
3963 let mut err_sticky = self
3964 .worker_error_display
3965 .remove(&w.instance_id)
3966 .unwrap_or_default();
3967 err_sticky.observe(w.last_error.as_deref(), now);
3968 if err_sticky.shown(now).is_some() {
3969 next_errors.insert(w.instance_id.clone(), err_sticky);
3970 }
3971 }
3972 self.worker_step_display = next_display;
3973 self.worker_error_display = next_errors;
3974 self.hired_workers = workers;
3975 self.sync_worker_take_picker_from_hired();
3976 if let Some(id) = selected_id {
3977 if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
3978 self.workers_menu_index = idx;
3979 return;
3980 }
3981 }
3982 if self.workers_menu_index >= self.hired_workers.len() {
3983 self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
3984 }
3985 }
3986
3987 fn sync_worker_take_picker_from_hired(&mut self) {
3989 if !self.show_worker_take_picker {
3990 return;
3991 }
3992 let Some(picker) = self.worker_take_picker.clone() else {
3993 return;
3994 };
3995 let Some(worker) = self
3996 .hired_workers
3997 .iter()
3998 .find(|w| w.instance_id == picker.worker_instance_id)
3999 .cloned()
4000 else {
4001 self.show_worker_take_picker = false;
4002 self.worker_take_picker = None;
4003 self.worker_take_picker_index = 0;
4004 return;
4005 };
4006 let options: Vec<WorkerGiveOption> = worker
4007 .inventory
4008 .iter()
4009 .filter_map(|stack| {
4010 let item_instance_id = stack.item_instance_id?;
4011 let label = stack
4012 .display_name
4013 .clone()
4014 .unwrap_or_else(|| stack.template_id.clone());
4015 let label = if stack.quantity > 1 {
4016 format!("{label} ×{}", stack.quantity)
4017 } else {
4018 label
4019 };
4020 Some(WorkerGiveOption {
4021 item_instance_id,
4022 label,
4023 quantity: stack.quantity,
4024 template_id: stack.template_id.clone(),
4025 })
4026 })
4027 .collect();
4028 if options.is_empty() {
4029 self.show_worker_take_picker = false;
4030 self.worker_take_picker = None;
4031 self.worker_take_picker_index = 0;
4032 return;
4033 }
4034 let prev_id = picker
4035 .options
4036 .get(self.worker_take_picker_index)
4037 .map(|o| o.item_instance_id);
4038 let idx = prev_id
4039 .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
4040 .unwrap_or(0)
4041 .min(options.len().saturating_sub(1));
4042 let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
4043 let quantity = picker.quantity.clamp(1, max_qty);
4044 self.worker_take_picker_index = idx;
4045 self.worker_take_picker = Some(WorkerTakePicker {
4046 worker_instance_id: picker.worker_instance_id,
4047 worker_label: picker.worker_label,
4048 options,
4049 quantity,
4050 });
4051 }
4052
4053 pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
4055 self.worker_step_display
4056 .get(worker_instance_id)
4057 .map(|s| s.shown.as_str())
4058 .or_else(|| {
4059 self.hired_workers
4060 .iter()
4061 .find(|w| w.instance_id == worker_instance_id)
4062 .map(|w| w.step_label.as_str())
4063 })
4064 .unwrap_or("")
4065 }
4066
4067 pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
4069 let now = Instant::now();
4070 self.worker_error_display
4071 .get(worker_instance_id)
4072 .and_then(|s| s.shown(now))
4073 .or_else(|| {
4074 self.hired_workers
4075 .iter()
4076 .find(|w| w.instance_id == worker_instance_id)
4077 .and_then(|w| w.last_error.as_deref())
4078 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
4079 })
4080 .filter(|e| !worker_error_is_hud_noise(e))
4081 }
4082
4083 fn apply_combat_hud(&mut self, combat: &CombatHud) {
4084 self.in_combat = combat.in_combat;
4085 self.auto_attack = combat.auto_attack;
4086 self.combat_has_los = combat.has_los;
4087 self.attack_cd_ticks = combat.attack_cd_ticks;
4088 self.gcd_ticks = combat.gcd_ticks;
4089 self.weapon_ability_id = combat.ability_id.clone();
4090 self.mainhand_template_id = combat.mainhand_template_id.clone();
4091 self.mainhand_label = combat.mainhand_label.clone();
4092 self.offhand_template_id = combat.offhand_template_id.clone();
4093 self.offhand_label = combat.offhand_label.clone();
4094 self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
4095 1
4096 } else {
4097 combat.mainhand_hand_slots
4098 };
4099 self.defense = combat.defense.clone();
4100 self.worn = combat.worn.iter().cloned().collect();
4101 self.carry_mass = combat.carry_mass;
4102 self.carry_mass_max = combat.carry_mass_max;
4103 self.encumbrance = combat.encumbrance;
4104 self.cast_progress = combat.cast.clone();
4105 self.timed_channel = combat.timed_channel.clone();
4106 self.ability_cooldowns = combat.ability_cooldowns.clone();
4107 self.blocking_active = combat.blocking_active;
4108 self.max_target_slots = combat.max_target_slots.max(1);
4109 self.combat_slots = combat.slots.clone();
4110 self.rotation_presets = combat.rotation_presets.clone();
4111 self.known_abilities = combat.known_abilities.clone();
4112 self.hotbar = combat.hotbar.clone();
4113 self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
4114 self.keychain_stacks = combat.keychain.clone();
4115 self.whisper_pouch_stacks = combat.whisper_pouch.clone();
4116 self.combat_target_detail = combat.target.clone();
4117 self.statuses = combat.statuses.clone();
4118 self.combat_target = combat.target_entity_id;
4119 if combat.progression_xp_base > 0.0 {
4120 self.progression_curve = Some(flatland_protocol::ProgressionCurve {
4121 baseline_display: combat.progression_baseline,
4122 xp_base: combat.progression_xp_base,
4123 xp_growth: combat.progression_xp_growth,
4124 });
4125 }
4126 if let Some(xp) = &combat.progression_xp {
4127 if let Some(player) = &mut self.player {
4128 player.progression_xp = Some(xp.clone());
4129 if let Some(attrs) = combat.attributes {
4130 player.attributes = Some(attrs);
4131 }
4132 if let Some(skills) = &combat.skills {
4133 player.skills = Some(skills.clone());
4134 }
4135 }
4136 }
4137 if let Some(label) = &combat.target_label {
4138 self.combat_target_label = Some(label.clone());
4139 } else if let Some(id) = combat.target_entity_id {
4140 self.combat_target_label = self
4141 .entities
4142 .iter()
4143 .find(|e| e.id == id)
4144 .map(|e| e.label.clone())
4145 .or_else(|| self.combat_target_label.clone());
4146 }
4147 self.refresh_inventory_ui();
4148 }
4149
4150 pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
4152 self.combat_slots
4153 .iter()
4154 .find(|s| s.slot_index == slot)
4155 .and_then(|s| s.target_entity_id)
4156 .or_else(|| if slot == 1 { self.combat_target } else { None })
4157 }
4158
4159 pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
4162 if !(1..=9).contains(&slot_1_to_9) {
4163 return None;
4164 }
4165 self.hotbar
4166 .get((slot_1_to_9 - 1) as usize)
4167 .and_then(|a| a.as_deref())
4168 .filter(|id| !id.is_empty())
4169 }
4170
4171 pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
4173 let binding = self.hotbar_ability(slot_1_to_9)?;
4174 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
4175 let name = self
4176 .inventory_hints
4177 .get(template_id)
4178 .map(|h| h.display_name.as_str())
4179 .unwrap_or(template_id);
4180 let qty = self.inventory.get(template_id).copied().unwrap_or(0);
4181 Some(format!("{name}×{qty}"))
4182 } else {
4183 Some(binding.to_string())
4184 }
4185 }
4186
4187 pub fn loadout_ability_choices(&self) -> Vec<String> {
4189 let mut out = self.known_abilities.clone();
4190 let weapon = self.weapon_ability_id.trim();
4191 if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
4192 out.push(weapon.to_string());
4193 }
4194 out
4195 }
4196
4197 pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
4199 let mut out = Vec::new();
4200 for ability in self.loadout_ability_choices() {
4201 let meta = if ability == self.weapon_ability_id {
4202 Some("weapon".into())
4203 } else {
4204 None
4205 };
4206 out.push(LoadoutHotbarChoice {
4207 binding: ability.clone(),
4208 label: ability,
4209 meta,
4210 });
4211 }
4212 let mut consumables: Vec<(String, String, u32)> = Vec::new();
4213 for stack in &self.inventory_stacks {
4214 if Self::stack_is_item_grant(stack) {
4215 continue;
4216 }
4217 if self.inventory_item_category(&stack.template_id) != Some("consumable") {
4218 continue;
4219 }
4220 let qty = stack.quantity.max(1);
4221 if let Some((_, _, existing)) = consumables
4222 .iter_mut()
4223 .find(|(id, _, _)| id == &stack.template_id)
4224 {
4225 *existing = existing.saturating_add(qty);
4226 } else {
4227 let label = stack
4228 .display_name
4229 .clone()
4230 .or_else(|| {
4231 self.inventory_hints
4232 .get(&stack.template_id)
4233 .map(|h| h.display_name.clone())
4234 })
4235 .unwrap_or_else(|| stack.template_id.clone());
4236 consumables.push((stack.template_id.clone(), label, qty));
4237 }
4238 }
4239 consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
4240 for (template_id, label, qty) in consumables {
4241 out.push(LoadoutHotbarChoice {
4242 binding: flatland_protocol::hotbar_consumable_binding(&template_id),
4243 label: format!("{label} ×{qty}"),
4244 meta: Some("use".into()),
4245 });
4246 }
4247 out
4248 }
4249
4250 pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
4252 self.combat_candidates()
4253 }
4254
4255 pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
4257 let (px, py) = self.player_position();
4258 let dist = |id: EntityId| {
4259 self.entities
4260 .iter()
4261 .find(|e| e.id == id)
4262 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4263 .unwrap_or(f32::MAX)
4264 };
4265
4266 let mut allies = Vec::new();
4267 if let Some(me) = self.player.as_ref() {
4269 let alive = me
4270 .vitals
4271 .as_ref()
4272 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4273 .unwrap_or(true);
4274 if alive {
4275 allies.push((self.entity_id, "Yourself".into()));
4276 }
4277 }
4278 for entity in &self.entities {
4279 if entity.id == self.entity_id {
4280 continue;
4281 }
4282 if entity.vitals.is_some() {
4283 let alive = entity
4284 .vitals
4285 .as_ref()
4286 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4287 .unwrap_or(true);
4288 if alive {
4289 allies.push((entity.id, entity.label.clone()));
4290 }
4291 }
4292 }
4293 allies.sort_by(|(a, _), (b, _)| {
4294 if *a == self.entity_id {
4295 return std::cmp::Ordering::Less;
4296 }
4297 if *b == self.entity_id {
4298 return std::cmp::Ordering::Greater;
4299 }
4300 dist(*a)
4301 .partial_cmp(&dist(*b))
4302 .unwrap_or(std::cmp::Ordering::Equal)
4303 });
4304
4305 let mut monsters = self.combat_candidates();
4306 monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
4307 allies.into_iter().chain(monsters).collect()
4308 }
4309
4310 fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
4311 match slot_index {
4312 2 => self.t2_candidates(),
4313 _ => self.t1_candidates(),
4314 }
4315 }
4316
4317 pub fn pick_combat_target_at(
4319 &self,
4320 wx: f32,
4321 wy: f32,
4322 slot_index: u8,
4323 radius_m: f32,
4324 ) -> Option<(EntityId, String)> {
4325 let mut best: Option<(f32, EntityId, String)> = None;
4326 for (id, label) in self.candidates_for_slot(slot_index) {
4327 let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
4328 if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
4330 let d = distance(wx, wy, npc.x, npc.y);
4331 if d <= radius_m {
4332 best = match best {
4333 Some((bd, _, _)) if bd <= d => best,
4334 _ => Some((d, id, label)),
4335 };
4336 }
4337 }
4338 continue;
4339 };
4340 let d = distance(
4341 wx,
4342 wy,
4343 entity.transform.position.x,
4344 entity.transform.position.y,
4345 );
4346 if d <= radius_m {
4347 best = match best {
4348 Some((bd, _, _)) if bd <= d => best,
4349 _ => Some((d, id, label)),
4350 };
4351 }
4352 }
4353 best.map(|(_, id, label)| (id, label))
4354 }
4355
4356 pub(crate) fn restore_from_welcome(
4358 &mut self,
4359 session_id: SessionId,
4360 entity_id: EntityId,
4361 snapshot: &flatland_protocol::Snapshot,
4362 ) {
4363 self.clear_harvest_state();
4364 self.disconnect_reason = None;
4365 self.show_stats = false;
4366 self.show_craft_menu = false;
4367 self.show_shop_menu = false;
4368 self.shop_catalog = None;
4369 self.show_inventory_menu = false;
4370 self.session_id = session_id;
4371 self.entity_id = entity_id;
4372 self.connected = true;
4373 self.apply_snapshot_fields(snapshot, entity_id);
4374 if let Some(combat) = &snapshot.combat {
4375 self.apply_combat_hud(combat);
4376 let stacks = self.inventory_stacks.clone();
4377 self.sync_inventory_from_stacks(&stacks);
4378 }
4379 }
4380
4381 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
4382 self.tick = delta.tick;
4383 self.world_clock = delta.world_clock;
4384
4385 if delta.entities.is_empty() {
4387 self.ground_drops = delta.ground_drops.clone();
4388 self.combat_fx = delta.combat_fx.clone();
4389 self.property_plots = delta.property_plots.clone();
4390 self.apply_terrain_overlays(&delta.terrain_overlays);
4391 if let Some(combat) = &delta.combat {
4392 self.apply_combat_hud(combat);
4393 let stacks = self.inventory_stacks.clone();
4394 self.sync_inventory_from_stacks(&stacks);
4395 }
4396 self.refresh_whisper_range();
4398 return;
4399 }
4400 if !delta.buildings.is_empty() {
4401 self.buildings = delta.buildings.clone();
4402 }
4403 if !delta.blueprints.is_empty() {
4404 self.blueprints = delta.blueprints.clone();
4405 }
4406 self.sync_inventory_from_stacks(&delta.inventory);
4407
4408 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
4409 self.player = Some(updated.clone());
4410 }
4411 self.entities = delta.entities.clone();
4412 if self.player.is_none() {
4413 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
4414 }
4415
4416 self.sync_interior_map_context();
4417
4418 if !delta.resource_nodes.is_empty() {
4422 self.resource_nodes = delta.resource_nodes.clone();
4423 } else if delta.interior_map.is_some()
4424 || self.effective_inside_building().is_some()
4425 {
4426 self.resource_nodes = delta.resource_nodes.clone();
4427 }
4428 self.ground_drops = delta.ground_drops.clone();
4429 if self
4430 .player
4431 .as_ref()
4432 .is_none_or(|p| p.inside_building.is_none())
4433 {
4434 self.placed_containers = delta.placed_containers.clone();
4435 }
4436 if !delta.doors.is_empty() {
4437 self.doors = delta.doors.clone();
4438 }
4439 if self.effective_inside_building().is_some() {
4440 if let Some(map) = &delta.interior_map {
4441 self.interior_map = Some(map.clone());
4442 }
4443 } else {
4444 self.interior_map = None;
4445 }
4446 self.sync_interior_z_bands();
4447 self.npcs = delta.npcs.clone();
4449 if !delta.quest_log.is_empty() {
4450 self.quest_log = delta.quest_log.clone();
4451 }
4452 self.apply_hired_workers(delta.hired_workers.clone());
4453 if !delta.interactables.is_empty() {
4454 self.interactables = delta.interactables.clone();
4455 }
4456 if delta.ledger.is_some() {
4457 self.ledger = delta.ledger.clone();
4458 }
4459 if delta.career.is_some() {
4460 self.career = delta.career.clone();
4461 }
4462 self.combat_fx = delta.combat_fx.clone();
4463 if !delta.property_plots.is_empty() {
4465 self.property_plots = delta.property_plots.clone();
4466 }
4467 self.apply_terrain_overlays(&delta.terrain_overlays);
4468 if let Some(combat) = &delta.combat {
4469 self.apply_combat_hud(combat);
4470 let stacks = self.inventory_stacks.clone();
4471 self.sync_inventory_from_stacks(&stacks);
4472 } else {
4473 self.refresh_inventory_ui();
4474 }
4475 self.refresh_whisper_range();
4476 }
4477
4478 fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
4481 self.terrain_zones
4482 .retain(|z| !z.id.starts_with("rt:"));
4483 self.terrain_zones.extend(overlays.iter().cloned());
4484 }
4485
4486 fn refresh_whisper_range(&mut self) {
4489 let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
4490 return;
4491 };
4492 let (px, py) = self.player_position();
4493 let in_range = self.entities.iter().any(|e| {
4494 e.id == peer
4495 && distance(
4496 px,
4497 py,
4498 e.transform.position.x,
4499 e.transform.position.y,
4500 ) <= INTERACTION_RADIUS_M
4501 });
4502 if !in_range {
4503 self.social_chat.cancel_whisper_out_of_range();
4504 }
4505 }
4506
4507 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
4509 let (px, py) = self.player_position();
4510 let mut out = Vec::new();
4511 for npc in &self.npcs {
4512 let Some(eid) = npc.entity_id else {
4513 continue;
4514 };
4515 let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
4516 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
4517 if alive && has_hp {
4518 out.push((eid, npc.label.clone()));
4519 }
4520 }
4521 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
4522 let dist = |id: EntityId| {
4523 self.entities
4524 .iter()
4525 .find(|e| e.id == id)
4526 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4527 .unwrap_or(f32::MAX)
4528 };
4529 dist(*a_id)
4530 .partial_cmp(&dist(*b_id))
4531 .unwrap_or(std::cmp::Ordering::Equal)
4532 .then_with(|| a_label.cmp(b_label))
4533 .then_with(|| a_id.cmp(b_id))
4534 });
4535 out
4536 }
4537
4538 pub fn refresh_combat_target_label(&mut self) {
4539 let Some(id) = self.combat_target else {
4540 return;
4541 };
4542 if let Some((_, label)) = self
4543 .combat_candidates()
4544 .into_iter()
4545 .find(|(eid, _)| *eid == id)
4546 {
4547 self.combat_target_label = Some(label);
4548 } else if let Some(label) = self
4549 .entities
4550 .iter()
4551 .find(|e| e.id == id)
4552 .map(|e| e.label.clone())
4553 {
4554 self.combat_target_label = Some(label);
4555 }
4556 }
4557
4558 pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
4559 self.quest_log
4560 .iter()
4561 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
4562 .collect()
4563 }
4564
4565 pub fn has_worker_lodging(&self) -> bool {
4567 self.free_worker_lodging_slots() > 0
4568 }
4569
4570 pub fn free_worker_lodging_slots(&self) -> i64 {
4572 let slots: u32 = self
4573 .placed_containers
4574 .iter()
4575 .filter(|c| match (self.character_id, c.owner_character_id) {
4576 (Some(me), Some(owner)) => me == owner,
4577 (Some(_), None) => false,
4578 (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
4579 })
4580 .map(|c| c.worker_lodging_capacity.unwrap_or(0))
4581 .sum();
4582 let used = self.hired_workers.len() as u32;
4583 slots as i64 - used as i64
4584 }
4585
4586 pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
4588 let mut names: Vec<String> = self
4589 .hired_workers
4590 .iter()
4591 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
4592 .map(|w| w.label.clone())
4593 .collect();
4594 names.sort();
4595 names
4596 }
4597
4598 pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
4600 let is_lodging = self
4601 .placed_containers
4602 .iter()
4603 .find(|c| c.id == container_id)
4604 .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
4605 if !is_lodging {
4606 return None;
4607 }
4608 let names = self.lodging_occupant_labels(container_id);
4609 Some(if names.is_empty() {
4610 "vacant".into()
4611 } else {
4612 names.join(", ")
4613 })
4614 }
4615
4616 pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
4617 self.quest_log
4618 .iter()
4619 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
4620 .or_else(|| {
4621 self.quest_log
4622 .iter()
4623 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
4624 })
4625 }
4626
4627 pub fn nearest_interact_target(&self) -> Option<String> {
4629 let (px, py) = self.player_position();
4630 let inside = self.effective_inside_building();
4631
4632 #[derive(Clone, Copy, PartialEq, Eq)]
4633 enum Kind {
4634 Player,
4635 Npc,
4636 HiredWorker,
4637 QuestBoard,
4638 ExitDoor,
4639 EnterDoor,
4640 Well,
4641 Water,
4642 }
4643
4644 fn kind_priority(kind: Kind) -> u8 {
4645 match kind {
4646 Kind::Player => 0,
4647 Kind::Npc => 0,
4648 Kind::HiredWorker => 0,
4649 Kind::QuestBoard => 1,
4650 Kind::ExitDoor => 2,
4651 Kind::EnterDoor => 3,
4652 Kind::Well => 4,
4653 Kind::Water => 5,
4654 }
4655 }
4656
4657 let mut best: Option<(f32, Kind, String)> = None;
4658
4659 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
4660 if dist > max {
4661 return;
4662 }
4663 let replace = match best {
4664 None => true,
4665 Some((bd, _bk, _)) if dist < bd - 0.05 => true,
4666 Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
4667 kind_priority(kind) < kind_priority(bk)
4668 }
4669 _ => false,
4670 };
4671 if replace {
4672 best = Some((dist, kind, id));
4673 }
4674 };
4675
4676 for npc in &self.npcs {
4677 consider(
4678 distance(px, py, npc.x, npc.y),
4679 INTERACTION_RADIUS_M,
4680 Kind::Npc,
4681 npc.id.clone(),
4682 );
4683 }
4684
4685 for worker in &self.hired_workers {
4686 consider(
4687 distance(px, py, worker.x, worker.y),
4688 INTERACTION_RADIUS_M,
4689 Kind::HiredWorker,
4690 worker.instance_id.clone(),
4691 );
4692 }
4693
4694 for entity in &self.entities {
4695 if entity.id == self.entity_id || entity.vitals.is_none() || entity.label.trim().is_empty()
4696 {
4697 continue;
4698 }
4699 if self
4701 .hired_workers
4702 .iter()
4703 .any(|w| w.entity_id == entity.id)
4704 {
4705 continue;
4706 }
4707 consider(
4708 distance(
4709 px,
4710 py,
4711 entity.transform.position.x,
4712 entity.transform.position.y,
4713 ),
4714 INTERACTION_RADIUS_M,
4715 Kind::Player,
4716 entity.id.to_string(),
4717 );
4718 }
4719
4720 for door in &self.doors {
4721 if let Some(ref bid) = inside {
4722 if door.building_id != *bid {
4723 continue;
4724 }
4725 let is_exit = door.portal.is_some();
4726 let max = if is_exit {
4727 INTERACTION_RADIUS_M
4728 } else {
4729 DOOR_INTERACTION_RADIUS_M
4730 };
4731 let kind = if is_exit {
4732 Kind::ExitDoor
4733 } else {
4734 Kind::EnterDoor
4735 };
4736 consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
4737 continue;
4738 }
4739 consider(
4740 distance(px, py, door.x, door.y),
4741 DOOR_INTERACTION_RADIUS_M,
4742 Kind::EnterDoor,
4743 door.id.clone(),
4744 );
4745 }
4746
4747 if inside.is_none() {
4748 for inter in &self.interactables {
4749 if inter.kind == "quest_board" {
4750 consider(
4751 distance(px, py, inter.x, inter.y),
4752 QUEST_BOARD_INTERACTION_RADIUS_M,
4753 Kind::QuestBoard,
4754 inter.id.clone(),
4755 );
4756 }
4757 }
4758 for building in &self.buildings {
4759 if !building.tags.iter().any(|t| t == "well") {
4760 continue;
4761 }
4762 consider(
4763 distance(px, py, building.x, building.y),
4764 INTERACTION_RADIUS_M,
4765 Kind::Well,
4766 building.id.clone(),
4767 );
4768 }
4769 if self.in_shallow_water() {
4770 consider(
4771 0.0,
4772 INTERACTION_RADIUS_M,
4773 Kind::Water,
4774 "water_source".into(),
4775 );
4776 }
4777 }
4778
4779 best.map(|(_, _, id)| id)
4780 }
4781
4782 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
4784 if self.effective_inside_building().is_some() {
4785 return None;
4786 }
4787 let (px, py) = self.player_position();
4788 self.interactables
4789 .iter()
4790 .filter(|i| i.kind == "quest_board")
4791 .map(|i| {
4792 let label = if i.label.is_empty() {
4793 "Quest board".to_string()
4794 } else {
4795 i.label.clone()
4796 };
4797 (label, distance(px, py, i.x, i.y))
4798 })
4799 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
4800 }
4801
4802 pub fn template_display_name(&self, template_id: &str) -> String {
4804 self.inventory_hints
4805 .get(template_id)
4806 .map(|h| h.display_name.clone())
4807 .filter(|n| !n.is_empty())
4808 .unwrap_or_else(|| humanize_template_id(template_id))
4809 }
4810
4811 pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
4813 use crate::worker_route_editor::{
4814 node_candidates, node_candidates_stable, route_editor_lodging_anchor,
4815 };
4816 let lodging = self
4817 .worker_route_editor
4818 .as_ref()
4819 .and_then(|ed| ed.lodging_container_id.as_deref());
4820 match route_editor_lodging_anchor(lodging, &self.placed_containers) {
4821 Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
4822 None => node_candidates_stable(&self.resource_nodes),
4823 }
4824 }
4825
4826 pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
4827 if dist_m.is_nan() {
4828 return "—".into();
4829 }
4830 let from_bed = self
4831 .worker_route_editor
4832 .as_ref()
4833 .and_then(|ed| ed.lodging_container_id.as_deref())
4834 .and_then(|id| {
4835 self.placed_containers
4836 .iter()
4837 .find(|c| c.id == id)
4838 .map(|c| c.display_name.clone())
4839 });
4840 match from_bed {
4841 Some(bed) => format!("{dist_m:.0}m from {bed}"),
4842 None => format!("{dist_m:.0}m"),
4843 }
4844 }
4845
4846 pub fn placed_container_public_label(
4848 &self,
4849 c: &flatland_protocol::PlacedContainerView,
4850 ) -> String {
4851 let is_owner = match (self.character_id, c.owner_character_id) {
4852 (Some(me), Some(owner)) => me == owner,
4853 _ => false,
4854 };
4855 if is_owner {
4856 c.display_name.clone()
4857 } else {
4858 self.template_display_name(&c.template_id)
4859 }
4860 }
4861
4862 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
4864 let mut out = Vec::new();
4865 for stack in &self.inventory_stacks {
4866 if stack.template_id == KEY_TEMPLATE {
4867 out.push(KeychainEntry {
4868 stack: stack.clone(),
4869 stowed: false,
4870 });
4871 }
4872 }
4873 for stack in &self.keychain_stacks {
4874 if stack.template_id == KEY_TEMPLATE {
4875 out.push(KeychainEntry {
4876 stack: stack.clone(),
4877 stowed: true,
4878 });
4879 }
4880 }
4881 out
4882 }
4883
4884 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
4886 if stack.template_id != KEY_TEMPLATE {
4887 return None;
4888 }
4889 if let Some(name) = stack
4890 .props
4891 .get(PROP_OPENS_CONTAINER_NAME)
4892 .filter(|n| !n.is_empty())
4893 {
4894 return Some(name.clone());
4895 }
4896 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
4897 self.container_name_for_lock_id(opens)
4898 }
4899
4900 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
4902 if stack.template_id == KEY_TEMPLATE {
4903 self.template_display_name(KEY_TEMPLATE)
4904 } else {
4905 stack
4906 .display_name
4907 .clone()
4908 .unwrap_or_else(|| stack.template_id.clone())
4909 }
4910 }
4911
4912 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
4914 if stack.template_id != KEY_TEMPLATE {
4915 return String::new();
4916 }
4917 match self.key_pair_chest_label(stack) {
4918 Some(chest) if self.key_drop_blocked(stack) => {
4919 format!(" [key for {chest} — can't drop while locked]")
4920 }
4921 Some(chest) => format!(" [key for {chest}]"),
4922 None => " [key — unpaired]".into(),
4923 }
4924 }
4925
4926 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
4928 for c in &self.placed_containers {
4929 if c.lock_id.as_deref() == Some(lock) {
4930 return Some(c.display_name.clone());
4931 }
4932 }
4933 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
4934 self.worn
4935 .values()
4936 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
4937 })
4938 }
4939
4940 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
4942 if stack.template_id != KEY_TEMPLATE {
4943 return false;
4944 }
4945 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
4946 return false;
4947 };
4948 for c in &self.placed_containers {
4949 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
4950 return true;
4951 }
4952 }
4953 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
4954 return true;
4955 }
4956 self.worn
4957 .values()
4958 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
4959 }
4960
4961 pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
4963 stack.template_id == PROPERTY_DEED_TEMPLATE
4964 }
4965
4966 pub fn is_property_deed_template(template_id: &str) -> bool {
4967 template_id == PROPERTY_DEED_TEMPLATE
4968 }
4969
4970 pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
4971 stack
4972 .props
4973 .get("plot_id")
4974 .and_then(|s| uuid::Uuid::parse_str(s).ok())
4975 }
4976
4977 pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
4979 let (px, py) = self.player_position();
4980 let (cx, cy) = self.farm_plot_cell_under_player()?;
4981 let tx = cx as f32 + 0.5;
4982 let ty = cy as f32 + 0.5;
4983 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
4984 return None;
4985 }
4986 let kind = self
4987 .terrain_at(tx, ty)
4988 .or_else(|| self.terrain_at(px, py));
4989 if kind == Some(TerrainKindView::Tilled) {
4990 return None;
4991 }
4992 if matches!(
4993 kind,
4994 Some(TerrainKindView::ShallowWater)
4995 | Some(TerrainKindView::DeepWater)
4996 | Some(TerrainKindView::Rock)
4997 ) {
4998 return None;
4999 }
5000 Some((tx, ty))
5001 }
5002
5003 fn container_name_in_stacks(
5004 stacks: &[flatland_protocol::ItemStack],
5005 lock: &str,
5006 ) -> Option<String> {
5007 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
5008 for s in stacks {
5009 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
5010 return Some(GameState::stack_container_label(s));
5011 }
5012 if let Some(name) = walk(&s.contents, lock) {
5013 return Some(name);
5014 }
5015 }
5016 None
5017 }
5018 walk(stacks, lock)
5019 }
5020
5021 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
5022 stack
5023 .props
5024 .get(PROP_CUSTOM_NAME)
5025 .cloned()
5026 .or_else(|| stack.display_name.clone())
5027 .unwrap_or_else(|| stack.template_id.clone())
5028 }
5029
5030 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5031 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5032 for s in stacks {
5033 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
5034 return true;
5035 }
5036 if walk(&s.contents, lock) {
5037 return true;
5038 }
5039 }
5040 false
5041 }
5042 walk(stacks, lock)
5043 }
5044
5045 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
5046 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
5047 return Some(stack.clone());
5048 }
5049 for worn in self.worn.values() {
5050 if worn.item_instance_id == Some(instance_id) {
5051 return Some(worn.clone());
5052 }
5053 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
5054 return Some(stack.clone());
5055 }
5056 }
5057 None
5058 }
5059
5060 pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
5062 self.property_zones
5063 .iter()
5064 .enumerate()
5065 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5066 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5067 .map(|(_, z)| z)
5068 }
5069
5070 pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
5072 self.tax_zones
5073 .iter()
5074 .enumerate()
5075 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5076 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5077 .map(|(_, z)| z)
5078 }
5079
5080 pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
5082 let mut max_bps = 0u32;
5083 let mut y = y0 + 0.5;
5084 while y < y1 {
5085 let mut x = x0 + 0.5;
5086 while x < x1 {
5087 if let Some(tz) = self.tax_zone_at(x, y) {
5088 max_bps = max_bps.max(tz.rate_bps);
5089 }
5090 x += 1.0;
5091 }
5092 y += 1.0;
5093 }
5094 max_bps
5095 }
5096
5097 pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5099 let mode = self.claim_mode.as_ref()?;
5100 let w = mode.width_m.max(1) as f32;
5101 let h = mode.height_m.max(1) as f32;
5102 Some((mode.anchor_x, mode.anchor_y, mode.anchor_x + w, mode.anchor_y + h))
5103 }
5104
5105 pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5107 let mode = self.relocate_mode.as_ref()?;
5108 let x0 = mode.cursor_x.floor();
5109 let y0 = mode.cursor_y.floor();
5110 Some((x0, y0, x0 + 1.0, y0 + 1.0))
5111 }
5112
5113 pub fn claim_quote(
5116 &self,
5117 ) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
5118 let mode = self.claim_mode.as_ref()?;
5119 let zone = self
5120 .property_zones
5121 .iter()
5122 .find(|z| z.id == mode.zone_id)?;
5123 let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
5124 let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
5125 let zone_area = zone_view_area_m2(zone).max(1.0);
5126 let area_frac = (area / zone_area).clamp(0.0, 1.0);
5127 let weight = self
5128 .property_plot_settings
5129 .as_ref()
5130 .map(|s| s.tax_premium_weight)
5131 .unwrap_or(0.5)
5132 .max(0.0);
5133 let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
5134 let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
5135 let purchase = ((zone.crown_price_copper as f64)
5136 * (area_frac as f64)
5137 * (premium as f64))
5138 .ceil()
5139 .max(0.0) as u64;
5140 let upkeep = if zone.upkeep_copper_per_day == 0 {
5141 0
5142 } else {
5143 ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
5144 .ceil()
5145 .max(1.0) as u64
5146 };
5147 let copper = crate::currency::copper_from_counts(&self.inventory);
5148 let can_afford = copper >= purchase;
5149 let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
5150 Some((purchase, upkeep, area, premium, can_afford, valid, reason))
5151 }
5152
5153 fn validate_claim_footprint(
5154 &self,
5155 zone: &flatland_protocol::PropertyZoneView,
5156 x0: f32,
5157 y0: f32,
5158 x1: f32,
5159 y1: f32,
5160 area: f32,
5161 ) -> (bool, String) {
5162 let min_area = self
5163 .property_plot_settings
5164 .as_ref()
5165 .map(|s| s.min_plot_area_m2)
5166 .unwrap_or(4.0);
5167 if area + f32::EPSILON < min_area {
5168 return (false, "plot too small".into());
5169 }
5170 if zone.max_area_m2.is_some_and(|m| area > m) {
5171 return (false, "plot exceeds max area".into());
5172 }
5173 if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
5174 return (false, "plot must lie inside the property zone".into());
5175 }
5176 if self.property_plots.iter().any(|p| {
5177 rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1)
5178 }) {
5179 return (false, "plot overlaps an existing claim".into());
5180 }
5181 (true, String::new())
5182 }
5183
5184 pub fn free_property_zone_under_player(
5186 &self,
5187 ) -> Option<&flatland_protocol::PropertyZoneView> {
5188 let (px, py) = self.player_position();
5189 let zone = self.property_zone_at(px, py)?;
5190 if self
5191 .property_plots
5192 .iter()
5193 .any(|p| point_in_plot(px, py, p))
5194 {
5195 return None;
5196 }
5197 Some(zone)
5198 }
5199
5200 pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5202 let (px, py) = self.player_position();
5203 self.property_plots
5204 .iter()
5205 .find(|p| p.is_mine && point_in_plot(px, py, p))
5206 }
5207
5208 pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5210 let (px, py) = self.player_position();
5211 self.property_plots
5212 .iter()
5213 .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
5214 }
5215
5216 pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
5218 if self.farmable_plot_under_player().is_none() {
5219 return None;
5220 }
5221 let (px, py) = self.player_position();
5222 Some((px.floor() as i32, py.floor() as i32))
5223 }
5224
5225 fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
5226 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5227 self.resource_nodes.iter().any(|n| {
5228 let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
5229 ncx == cx && ncy == cy
5230 || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
5231 })
5232 }
5233
5234 fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
5235 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5236 let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
5237 || self
5238 .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
5239 .is_some_and(|z| z.kind == TerrainKindView::Tilled);
5240 if !tilled {
5241 return false;
5242 }
5243 !self.resource_node_occupies_farm_cell(cx, cy)
5244 }
5245
5246 pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
5248 let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
5249 return false;
5250 };
5251 self.free_tilled_plant_slot_at(cx, cy)
5252 }
5253
5254 pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
5256 let (px, py) = self.player_position();
5257 for dy in -2..=2 {
5258 for dx in -2..=2 {
5259 let cx = px.floor() as i32 + dx;
5260 let cy = py.floor() as i32 + dy;
5261 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5262 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5263 continue;
5264 }
5265 if self.free_tilled_plant_slot_at(cx, cy) {
5266 return true;
5267 }
5268 }
5269 }
5270 false
5271 }
5272
5273 fn stack_is_farm_seed(stack: &flatland_protocol::ItemStack) -> bool {
5274 stack.quantity > 0
5275 && (stack.props.contains_key("seed_for")
5276 || stack.template_id.ends_with("_seed")
5277 || stack.template_id == "potato_seed"
5278 || stack.template_id == "carrot_seed")
5279 }
5280
5281 pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
5283 let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
5284 fn walk(
5285 stacks: &[flatland_protocol::ItemStack],
5286 counts: &mut std::collections::HashMap<String, u32>,
5287 ) {
5288 for s in stacks {
5289 if GameState::stack_is_farm_seed(s) {
5290 *counts.entry(s.template_id.clone()).or_default() += s.quantity;
5291 }
5292 walk(&s.contents, counts);
5293 }
5294 }
5295 walk(&self.inventory_stacks, &mut counts);
5296 for worn in self.worn.values() {
5297 walk(std::slice::from_ref(worn), &mut counts);
5298 }
5299 let mut out: Vec<_> = counts
5300 .into_iter()
5301 .map(|(template_id, quantity)| {
5302 let label = self
5303 .inventory_hints
5304 .get(&template_id)
5305 .map(|h| h.display_name.clone())
5306 .filter(|n| !n.trim().is_empty())
5307 .unwrap_or_else(|| humanize_template_id(&template_id));
5308 (template_id, quantity, label)
5309 })
5310 .collect();
5311 out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
5312 out
5313 }
5314
5315 pub fn first_farm_seed_template(&self) -> Option<String> {
5317 self.farm_seed_entries()
5318 .into_iter()
5319 .next()
5320 .map(|(id, _, _)| id)
5321 }
5322
5323 pub fn clamp_plant_menu(&mut self) {
5324 let n = self.farm_seed_entries().len();
5325 if n == 0 {
5326 self.plant_menu_index = 0;
5327 self.plant_quantity = 1;
5328 return;
5329 }
5330 self.plant_menu_index = self.plant_menu_index.min(n - 1);
5331 let max_qty = self
5332 .farm_seed_entries()
5333 .get(self.plant_menu_index)
5334 .map(|(_, q, _)| *q)
5335 .unwrap_or(1)
5336 .max(1);
5337 self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
5338 }
5339
5340 pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
5341 let entries = self.farm_seed_entries();
5342 let (id, max, label) = entries.get(self.plant_menu_index)?;
5343 let qty = self.plant_quantity.min(*max).max(1);
5344 Some((id.clone(), qty, label.clone()))
5345 }
5346
5347 pub fn location_context_lines(&self) -> Vec<ContextLine> {
5349 let (px, py) = self.player_position();
5350 let inside = self.effective_inside_building();
5351 let mut lines = Vec::new();
5352
5353 if let Some(kind) = self.terrain_at(px, py) {
5354 lines.push(ContextLine {
5355 on_top: true,
5356 text: format!("Terrain: {}", terrain_kind_label(kind)),
5357 });
5358 }
5359
5360 if let Some(id) = inside.as_ref() {
5361 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
5362 lines.push(ContextLine {
5363 on_top: true,
5364 text: format!("Inside: {}", b.label),
5365 });
5366 }
5367 }
5368
5369 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
5370
5371 for node in &self.resource_nodes {
5372 if node.id.starts_with("preview:") {
5373 continue;
5374 }
5375 let dist = distance(px, py, node.x, node.y);
5376 if dist > NEARBY_SCAN_M {
5377 continue;
5378 }
5379 let on_top = dist <= ON_TOP_RADIUS_M;
5380 let prefix = if on_top { "On" } else { "Near" };
5381 let name = resource_node_near_display_label(&node.label);
5382 let action = resource_node_near_action_suffix(node);
5383 nearby.push((
5384 dist,
5385 ContextLine {
5386 on_top,
5387 text: format!("{prefix}: {name} ({dist:.1}m){action}"),
5388 },
5389 ));
5390 }
5391
5392 for drop in &self.ground_drops {
5393 let dist = distance(px, py, drop.x, drop.y);
5394 if dist > INTERACTION_RADIUS_M {
5395 continue;
5396 }
5397 let on_top = dist <= ON_TOP_RADIUS_M;
5398 let name = self.template_display_name(&drop.template_id);
5399 let prefix = if on_top { "On" } else { "Near" };
5400 let qty = if drop.quantity > 1 {
5401 format!(" ×{}", drop.quantity)
5402 } else {
5403 String::new()
5404 };
5405 nearby.push((
5406 dist,
5407 ContextLine {
5408 on_top,
5409 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
5410 },
5411 ));
5412 }
5413
5414 for c in &self.placed_containers {
5415 let dist = distance(px, py, c.x, c.y);
5416 if dist > CONTAINER_RANGE_M {
5417 continue;
5418 }
5419 let on_top = dist <= ON_TOP_RADIUS_M;
5420 let name = self.placed_container_public_label(c);
5421 let lock = if c.locked { " [locked]" } else { "" };
5422 let prefix = if on_top { "On" } else { "Near" };
5423 nearby.push((
5424 dist,
5425 ContextLine {
5426 on_top,
5427 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
5428 },
5429 ));
5430 }
5431
5432 for npc in &self.npcs {
5433 let dist = distance(px, py, npc.x, npc.y);
5434 if dist > NEARBY_SCAN_M {
5435 continue;
5436 }
5437 let on_top = dist <= ON_TOP_RADIUS_M;
5438 let prefix = if on_top { "On" } else { "Near" };
5439 nearby.push((
5440 dist,
5441 ContextLine {
5442 on_top,
5443 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
5444 },
5445 ));
5446 }
5447
5448 for door in &self.doors {
5449 let dist = distance(px, py, door.x, door.y);
5450 if dist > DOOR_INTERACTION_RADIUS_M {
5451 continue;
5452 }
5453 let building = self
5454 .buildings
5455 .iter()
5456 .find(|b| b.id == door.building_id)
5457 .map(|b| b.label.as_str())
5458 .unwrap_or(door.building_id.as_str());
5459 let action = if inside.is_some() && door.portal.is_some() {
5460 "exit"
5461 } else {
5462 "enter"
5463 };
5464 nearby.push((
5465 dist,
5466 ContextLine {
5467 on_top: dist <= ON_TOP_RADIUS_M,
5468 text: format!("{building} door ({dist:.1}m) — f {action}"),
5469 },
5470 ));
5471 }
5472
5473 if inside.is_none() {
5474 for inter in &self.interactables {
5475 if inter.kind != "quest_board" {
5476 continue;
5477 }
5478 let dist = distance(px, py, inter.x, inter.y);
5479 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
5480 continue;
5481 }
5482 let on_top = dist <= ON_TOP_RADIUS_M;
5483 let prefix = if on_top { "On" } else { "Near" };
5484 let label = if inter.label.is_empty() {
5485 "Quest board".to_string()
5486 } else {
5487 inter.label.clone()
5488 };
5489 nearby.push((
5490 dist,
5491 ContextLine {
5492 on_top,
5493 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
5494 },
5495 ));
5496 }
5497 }
5498
5499 if self.in_shallow_water() {
5500 let already = self
5501 .terrain_at(px, py)
5502 .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
5503 if !already {
5504 nearby.push((
5505 0.0,
5506 ContextLine {
5507 on_top: true,
5508 text: "Shallow water — f fill bottle".into(),
5509 },
5510 ));
5511 } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
5512 line.text.push_str(" — f fill bottle");
5513 }
5514 }
5515
5516 if self.claim_mode.is_some() {
5517 nearby.push((
5518 0.0,
5519 ContextLine {
5520 on_top: true,
5521 text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
5522 .into(),
5523 },
5524 ));
5525 } else if let Some(plot) = self.my_plot_under_player() {
5526 let zone = plot
5527 .zone_label
5528 .as_deref()
5529 .filter(|s| !s.trim().is_empty())
5530 .or_else(|| {
5531 self.property_zones
5532 .iter()
5533 .find(|z| z.id == plot.property_zone_id)
5534 .and_then(|z| z.label.as_deref().filter(|s| !s.trim().is_empty()))
5535 })
5536 .unwrap_or(plot.property_zone_id.as_str());
5537 let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
5538 format!("Your plot ({zone}) — f again to sell to crown")
5539 } else {
5540 format!(
5541 "Your plot ({zone}) — c till · p plant · f harvest · o farm access · deed to sell"
5542 )
5543 };
5544 nearby.push((
5545 0.0,
5546 ContextLine {
5547 on_top: true,
5548 text: prompt,
5549 },
5550 ));
5551 } else if let Some(plot) = self.farmable_plot_under_player() {
5552 let owner = plot
5553 .owner_label
5554 .as_deref()
5555 .filter(|s| !s.trim().is_empty())
5556 .unwrap_or("owner");
5557 let disc = if plot.farm_public {
5558 plot.public_tax_discount_bps / 100
5559 } else {
5560 plot.farm_allow
5561 .iter()
5562 .find(|g| Some(g.character_id) == self.character_id)
5563 .map(|g| g.tax_discount_bps / 100)
5564 .unwrap_or(0)
5565 };
5566 nearby.push((
5567 0.0,
5568 ContextLine {
5569 on_top: true,
5570 text: format!(
5571 "Farming permitted — {owner} (tax −{disc}%) — c till · p plant · f harvest"
5572 ),
5573 },
5574 ));
5575 } else if let Some(zone) = self.free_property_zone_under_player() {
5576 let label = zone
5577 .label
5578 .as_deref()
5579 .filter(|s| !s.trim().is_empty())
5580 .unwrap_or(zone.id.as_str());
5581 nearby.push((
5582 0.0,
5583 ContextLine {
5584 on_top: true,
5585 text: format!("Claimable land: {label} — k buy plot"),
5586 },
5587 ));
5588 }
5589
5590 for entity in &self.entities {
5591 if entity.id == self.entity_id {
5592 continue;
5593 }
5594 let dist = distance(
5595 px,
5596 py,
5597 entity.transform.position.x,
5598 entity.transform.position.y,
5599 );
5600 if dist > NEARBY_SCAN_M {
5601 continue;
5602 }
5603 let label = if entity.label.is_empty() {
5604 format!("entity {}", entity.id)
5605 } else {
5606 entity.label.clone()
5607 };
5608 nearby.push((
5609 dist,
5610 ContextLine {
5611 on_top: dist <= ON_TOP_RADIUS_M,
5612 text: format!("Near: {label} ({dist:.1}m)"),
5613 },
5614 ));
5615 }
5616
5617 nearby.sort_by(|a, b| {
5618 a.0.partial_cmp(&b.0)
5619 .unwrap_or(std::cmp::Ordering::Equal)
5620 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
5621 });
5622 lines.extend(nearby.into_iter().map(|(_, l)| l));
5623
5624 if lines.is_empty() {
5625 lines.push(ContextLine {
5626 on_top: false,
5627 text: "(nothing notable nearby)".into(),
5628 });
5629 }
5630
5631 lines
5632 }
5633}
5634
5635#[derive(Debug, Clone)]
5637pub struct ContextLine {
5638 pub on_top: bool,
5639 pub text: String,
5640}
5641
5642const ON_TOP_RADIUS_M: f32 = 0.65;
5643const NEARBY_SCAN_M: f32 = 5.0;
5644
5645pub fn resource_node_near_display_label(label: &str) -> String {
5647 label
5648 .strip_suffix(" (growing)")
5649 .unwrap_or(label)
5650 .to_string()
5651}
5652
5653pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
5655 use flatland_protocol::ResourceNodeState;
5656 if let Some(p) = node.growth_progress {
5657 if p < 1.0 - f32::EPSILON {
5658 let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
5659 return format!(" (growing, {pct}%)");
5660 }
5661 return " — f harvest".to_string();
5662 }
5663 match node.state {
5664 ResourceNodeState::Available => " — f harvest".to_string(),
5665 ResourceNodeState::Harvesting => " (being harvested)".to_string(),
5666 ResourceNodeState::Cooldown => " (depleted)".to_string(),
5667 }
5668}
5669
5670fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
5671 use flatland_protocol::TerrainKindView;
5672 match kind {
5673 TerrainKindView::Grass => "Grass",
5674 TerrainKindView::Dirt => "Dirt",
5675 TerrainKindView::Tilled => "Tilled",
5676 TerrainKindView::Desert => "Desert",
5677 TerrainKindView::Hill => "Hills",
5678 TerrainKindView::Bog => "Bog",
5679 TerrainKindView::Beach => "Beach",
5680 TerrainKindView::ShallowWater => "Shallow water",
5681 TerrainKindView::DeepWater => "Deep water",
5682 TerrainKindView::Trail => "Trail",
5683 TerrainKindView::Road => "Road",
5684 TerrainKindView::Rock => "Rock",
5685 }
5686}
5687
5688fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
5689 crate::world_zones::zone_rects_contain(rects, x, y)
5690}
5691
5692fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
5693 zone.rects
5694 .iter()
5695 .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
5696 .sum()
5697}
5698
5699fn claim_rect_fully_inside_zone(
5700 zone: &flatland_protocol::PropertyZoneView,
5701 x0: f32,
5702 y0: f32,
5703 x1: f32,
5704 y1: f32,
5705) -> bool {
5706 let mut y = y0 + 0.5;
5707 while y < y1 {
5708 let mut x = x0 + 0.5;
5709 while x < x1 {
5710 if !zone_rects_contain(&zone.rects, x, y) {
5711 return false;
5712 }
5713 x += 1.0;
5714 }
5715 y += 1.0;
5716 }
5717 true
5718}
5719
5720fn rects_overlap_half_open(
5721 ax0: f32,
5722 ay0: f32,
5723 ax1: f32,
5724 ay1: f32,
5725 bx0: f32,
5726 by0: f32,
5727 bx1: f32,
5728 by1: f32,
5729) -> bool {
5730 ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
5731}
5732
5733fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
5734 x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
5735}
5736
5737fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
5738 p.zone_label
5739 .as_deref()
5740 .filter(|s| !s.trim().is_empty())
5741 .map(|s| s.to_string())
5742 .unwrap_or_else(|| format!("plot {}", &p.plot_id.to_string()[..8]))
5743}
5744
5745fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
5747 let a = x0.min(x1).floor();
5748 let b = y0.min(y1).floor();
5749 let mut c = x0.max(x1).ceil();
5750 let mut d = y0.max(y1).ceil();
5751 if (c - a) < 1.0 {
5752 c = a + 1.0;
5753 }
5754 if (d - b) < 1.0 {
5755 d = b + 1.0;
5756 }
5757 (a, b, c, d)
5758}
5759
5760fn humanize_template_id(template_id: &str) -> String {
5761 template_id
5762 .split('_')
5763 .map(|word| {
5764 let mut chars = word.chars();
5765 match chars.next() {
5766 None => String::new(),
5767 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
5768 }
5769 })
5770 .collect::<Vec<_>>()
5771 .join(" ")
5772}
5773
5774const HARVEST_RANGE_M: f32 = 1.5;
5776
5777pub struct GameClient<S: PlayConnection> {
5778 session: S,
5779 seq: Seq,
5780 pub state: GameState,
5781 last_move_forward: f32,
5782 last_move_strafe: f32,
5783}
5784
5785impl<S: PlayConnection> GameClient<S> {
5786 pub fn new(session: S) -> Self {
5787 let session_id = session.session_id();
5788 let entity_id = session.entity_id();
5789 let mut client = Self {
5790 session,
5791 seq: 0,
5792 last_move_forward: 0.0,
5793 last_move_strafe: 0.0,
5794 state: GameState {
5795 session_id,
5796 entity_id,
5797 character_id: None,
5798 tick: 0,
5799 chunk_rev: 0,
5800 content_rev: 0,
5801 publish_rev: 0,
5802 entities: Vec::new(),
5803 player: None,
5804 resource_nodes: Vec::new(),
5805 ground_drops: Vec::new(),
5806 placed_containers: Vec::new(),
5807 buildings: Vec::new(),
5808 doors: Vec::new(),
5809 interior_map: None,
5810 npcs: Vec::new(),
5811 blueprints: Vec::new(),
5812 world_x0: 0.0,
5813 world_y0: 0.0,
5814 world_width_m: 0.0,
5815 world_height_m: 0.0,
5816 terrain_zones: Vec::new(),
5817 z_platforms: Vec::new(),
5818 z_transitions: Vec::new(),
5819 world_clock: flatland_protocol::WorldClock::default(),
5820 inventory: std::collections::HashMap::new(),
5821 inventory_hints: std::collections::HashMap::new(),
5822 logs: VecDeque::new(),
5823 intents_sent: 0,
5824 ticks_received: 0,
5825 connected: false,
5826 disconnect_reason: None,
5827 show_stats: false,
5828 hud_log_hidden: false,
5829 show_equip_menu: false,
5830 equip_menu_index: 0,
5831 show_craft_menu: false,
5832 craft_menu_index: 0,
5833 craft_batch_quantity: 1,
5834 show_shop_menu: false,
5835 shop_catalog: None,
5836 bank_panel: None,
5837 bank_menu_index: 0,
5838 bank_ui_mode: BankUiMode::Menu,
5839 storage_panel: None,
5840 market_panel: None,
5841 market_menu_index: 0,
5842 market_filter: String::new(),
5843 market_filter_focused: false,
5844 market_category_filter: None,
5845 market_buy_confirm: None,
5846 market_ui_mode: MarketUiMode::Browse,
5847 storage_menu_index: 0,
5848 storage_ui_mode: StorageUiMode::Menu,
5849 shop_tab: ShopTab::default(),
5850 shop_menu_index: 0,
5851 shop_quantity: 1,
5852 shop_trade_log: VecDeque::new(),
5853 show_npc_verb_menu: false,
5854 npc_verb_target: None,
5855 npc_verb_index: 0,
5856 player_verbs: crate::social::PlayerVerbState::default(),
5857 social_chat: crate::social::SocialChatState::default(),
5858 trade_ui: crate::social::TradeUiState::default(),
5859 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
5860 show_npc_chat: false,
5861 npc_chat: None,
5862 show_inventory_menu: false,
5863 inventory_menu_index: 0,
5864 inventory_tab: InventoryTab::OnPerson,
5865 inventory_filter: String::new(),
5866 inventory_filter_focused: false,
5867 show_move_picker: false,
5868 show_rename_prompt: false,
5869 show_worker_rename: false,
5870 rename_buffer: String::new(),
5871 move_picker_index: 0,
5872 move_picker: None,
5873 show_grant_picker: false,
5874 grant_picker_index: 0,
5875 grant_picker: None,
5876 show_destroy_picker: false,
5877 destroy_confirm_pending: false,
5878 destroy_picker: None,
5879 combat_target: None,
5880 combat_target_label: None,
5881 combat_fx: Vec::new(),
5882 property_zones: Vec::new(),
5883 tax_zones: Vec::new(),
5884 growth_zones: Vec::new(),
5885 biome_zones: Vec::new(),
5886 property_plots: Vec::new(),
5887 property_plot_settings: None,
5888 claim_mode: None,
5889 relocate_mode: None,
5890 sell_plot_confirm: None,
5891 sell_plot_armed_at: None,
5892 show_plant_menu: false,
5893 plant_menu_index: 0,
5894 show_farm_access: false,
5895 farm_access_name_draft: String::new(),
5896 farm_access_discount_bps: 0,
5897 farm_access_index: 0,
5898 plant_quantity: 1,
5899 in_combat: false,
5900 auto_attack: true,
5901 combat_has_los: false,
5902 attack_cd_ticks: 0,
5903 gcd_ticks: 0,
5904 weapon_ability_id: "unarmed".into(),
5905 mainhand_template_id: None,
5906 mainhand_label: None,
5907 offhand_template_id: None,
5908 offhand_label: None,
5909 mainhand_hand_slots: 1,
5910 defense: None,
5911 worn: BTreeMap::new(),
5912 carry_mass: 0.0,
5913 carry_mass_max: 0.0,
5914 encumbrance: flatland_protocol::EncumbranceState::Light,
5915 inventory_stacks: Vec::new(),
5916 keychain_stacks: Vec::new(),
5917 whisper_pouch_stacks: Vec::new(),
5918 combat_target_detail: None,
5919 statuses: Vec::new(),
5920 cast_progress: None,
5921 timed_channel: None,
5922 ability_cooldowns: Vec::new(),
5923 blocking_active: false,
5924 max_target_slots: 1,
5925 combat_slots: Vec::new(),
5926 rotation_presets: Vec::new(),
5927 known_abilities: Vec::new(),
5928 hotbar: vec![None; 9],
5929 max_abilities_per_rotation: 0,
5930 show_loadout_menu: false,
5931 show_keychain_menu: false,
5932 keychain_menu_index: 0,
5933 show_rotation_editor: false,
5934 loadout_menu_index: 0,
5935 loadout_hotbar_slot: 1,
5936 loadout_ability_index: 0,
5937 loadout_focus_presets: false,
5938 rotation_editor: RotationEditorState::default(),
5939 harvest_in_progress: false,
5940 harvest_started_at: None,
5941 pending_craft_ack: None,
5942 pending_worker_job_ack: None,
5943 attending_worker_instance_id: None,
5944 quest_log: Vec::new(),
5945 interactables: Vec::new(),
5946 ledger: None,
5947 career: None,
5948 character_sheet_tab: CharacterSheetTab::Character,
5949 ledger_period: LedgerPeriod::Day,
5950 show_quest_offer: false,
5951 pending_quest_offer: None,
5952 show_quest_menu: false,
5953 quest_menu_index: 0,
5954 quest_withdraw_confirm: false,
5955 hired_workers: Vec::new(),
5956 show_workers_menu: false,
5957 workers_menu_index: 0,
5958 workers_menu_compact: false,
5959 worker_step_display: BTreeMap::new(),
5960 worker_error_display: BTreeMap::new(),
5961 show_worker_give_picker: false,
5962 worker_give_picker_index: 0,
5963 worker_give_picker: None,
5964 show_worker_give_target_picker: false,
5965 worker_give_target_picker_index: 0,
5966 worker_give_target_picker: None,
5967 show_worker_take_picker: false,
5968 worker_take_picker_index: 0,
5969 worker_take_picker: None,
5970 show_worker_teach_picker: false,
5971 worker_teach_picker_index: 0,
5972 worker_teach_picker: None,
5973 worker_route_editor: None,
5974 progression_curve: None,
5975 },
5976 };
5977 client.state.apply_client_ui_prefs();
5978 client
5979 }
5980
5981 pub fn entity_id(&self) -> EntityId {
5982 self.state.entity_id
5983 }
5984
5985 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
5986 if self.state.connected {
5987 return Ok(());
5988 }
5989
5990 loop {
5991 match self.session.next_event().await {
5992 Some(SessionEvent::Welcome {
5993 session_id,
5994 entity_id,
5995 snapshot,
5996 }) => {
5997 self.state
5998 .restore_from_welcome(session_id, entity_id, &snapshot);
5999 self.state.apply_client_ui_prefs();
6000 self.state.push_log(format!(
6001 "Connected — session {session_id}, entity {entity_id}"
6002 ));
6003 return Ok(());
6004 }
6005 Some(SessionEvent::Disconnected { .. }) => {
6006 anyhow::bail!("disconnected before welcome");
6007 }
6008 Some(_) => continue,
6009 None => anyhow::bail!("session closed before welcome"),
6010 }
6011 }
6012 }
6013
6014 pub fn drain_events(&mut self) {
6016 while let Some(event) = self.session.try_next_event() {
6017 if self.handle_event_sync(event).is_err() {
6018 break;
6019 }
6020 }
6021 }
6022
6023 pub async fn next_event(&mut self) -> Option<SessionEvent> {
6025 self.session.next_event().await
6026 }
6027
6028 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6029 self.handle_event_sync(event)
6030 }
6031
6032 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6033 match event {
6034 SessionEvent::Welcome {
6035 session_id,
6036 entity_id,
6037 snapshot,
6038 } => {
6039 let resumed = self.state.connected;
6040 self.state
6041 .restore_from_welcome(session_id, entity_id, &snapshot);
6042 if resumed {
6043 self.state.push_log(format!(
6044 "Session restored — session {session_id}, entity {entity_id}"
6045 ));
6046 }
6047 }
6048 SessionEvent::ContentUpdated { snapshot } => {
6049 self.state
6050 .apply_snapshot_fields(&snapshot, self.state.entity_id);
6051 self.state.push_log(format!(
6052 "World updated (content rev {})",
6053 snapshot.content_rev
6054 ));
6055 }
6056 SessionEvent::Tick(delta) => {
6057 self.state.apply_tick_fields(&delta, self.state.entity_id);
6058 self.state.ticks_received += 1;
6059 }
6060 SessionEvent::IntentAck {
6061 entity_id,
6062 seq,
6063 tick,
6064 } => {
6065 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
6066 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
6067 if *craft_seq == seq {
6068 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
6069 if batches > 1 {
6070 self.state.push_log(format!("Crafting {label} ×{batches}…"));
6071 } else {
6072 self.state.push_log(format!("Crafting {label}…"));
6073 }
6074 }
6075 }
6076 if self
6077 .state
6078 .pending_worker_job_ack
6079 .as_ref()
6080 .is_some_and(|p| p.seq == seq)
6081 {
6082 let pending = self.state.pending_worker_job_ack.take().unwrap();
6083 if pending.idle {
6084 self.state.push_log(format!(
6085 "Route cleared for {} — worker idle",
6086 pending.worker_label
6087 ));
6088 } else {
6089 self.state.push_log(format!(
6090 "Route saved for {} — {} stop(s), job loop active",
6091 pending.worker_label, pending.stop_count
6092 ));
6093 }
6094 if self
6095 .state
6096 .worker_route_editor
6097 .as_ref()
6098 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
6099 {
6100 self.close_worker_route_editor();
6101 }
6102 }
6103 }
6104 SessionEvent::Chat(msg) => {
6105 let label = match msg.channel {
6106 flatland_protocol::ChatChannel::Nearby => "nearby",
6107 flatland_protocol::ChatChannel::Direct => "speak",
6108 flatland_protocol::ChatChannel::Whisper => "whisper",
6109 flatland_protocol::ChatChannel::WhisperStone => "stone",
6110 };
6111 let clarity = match msg.clarity {
6112 flatland_protocol::ChatClarity::Clear => "",
6113 flatland_protocol::ChatClarity::Partial => "~",
6114 flatland_protocol::ChatClarity::Heavy => "…",
6115 };
6116 self.state.push_log(format!(
6117 "[{label}{clarity}] {}: {}",
6118 msg.from_name, msg.text
6119 ));
6120 let now_ms = std::time::SystemTime::now()
6121 .duration_since(std::time::UNIX_EPOCH)
6122 .map(|d| d.as_millis() as u64)
6123 .unwrap_or(0);
6124 self.state
6125 .social_chat
6126 .note_speech(&msg, self.state.entity_id, now_ms);
6127 self.state
6128 .social_chat
6129 .push(crate::social::ChatLogEntry::from_message(
6130 msg,
6131 self.state.entity_id,
6132 ));
6133 }
6134 SessionEvent::TradeOpened(panel) => {
6135 self.state.social_chat.pending_trade = None;
6136 let peer = panel.peer_name.clone();
6137 self.state.trade_ui.open(panel);
6138 self.state
6139 .social_chat
6140 .push_system(format!("Trade open with {peer} — p present · r ready · Esc cancel"));
6141 self.state
6142 .social_chat
6143 .push_cue(crate::social::AudioCue::TradeOpened);
6144 }
6145 SessionEvent::TradeClosed { reason } => {
6146 self.state.push_log(reason.clone());
6147 self.state.social_chat.push_system(reason);
6148 self.state.trade_ui.close();
6149 }
6150 SessionEvent::HarvestResult(result) => {
6151 self.state.clear_harvest_state();
6152 crate::harvest_trace!(
6153 entity_id = self.state.entity_id,
6154 node_id = %result.node_id,
6155 template = %result.item_template,
6156 quantity = result.quantity,
6157 client_tick = self.state.tick,
6158 "client applied harvest result"
6159 );
6160 let msg = if result.quantity == 0 {
6161 format!(
6162 "Harvested {} x0 — nothing dropped (loot table rolled empty)",
6163 result.item_template
6164 )
6165 } else {
6166 format!(
6167 "Harvested {} x{} (on the ground — press P to pick up)",
6168 result.item_template, result.quantity
6169 )
6170 };
6171 self.state.push_log(msg);
6172 }
6173 SessionEvent::CraftResult(result) => {
6174 for stack in &result.consumed {
6175 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
6176 *qty = qty.saturating_sub(stack.quantity);
6177 if *qty == 0 {
6178 self.state.inventory.remove(&stack.template_id);
6179 }
6180 }
6181 }
6182 for stack in &result.outputs {
6183 *self
6184 .state
6185 .inventory
6186 .entry(stack.template_id.clone())
6187 .or_insert(0) += stack.quantity;
6188 }
6189 if let Some(output) = result.outputs.first() {
6190 if result.batch_total > 1 {
6191 self.state.push_log(format!(
6192 "Crafted {} x{} ({}/{})",
6193 output.template_id,
6194 output.quantity,
6195 result.batch_index,
6196 result.batch_total
6197 ));
6198 } else {
6199 self.state.push_log(format!(
6200 "Crafted {} x{}",
6201 output.template_id, output.quantity
6202 ));
6203 }
6204 } else {
6205 self.state
6206 .push_log(format!("Craft finished: {}", result.blueprint_id));
6207 }
6208 }
6209 SessionEvent::Death(notice) => {
6210 self.state.clear_harvest_state();
6211 self.state.push_log(notice.message.clone());
6212 self.state.push_log(format!(
6213 "Respawned at ({:.1}, {:.1})",
6214 notice.respawn_x, notice.respawn_y
6215 ));
6216 }
6217 SessionEvent::Interaction(notice) => {
6218 if notice.message.starts_with("Harvest failed:") {
6219 self.state.clear_harvest_state();
6220 }
6221 if notice.message.starts_with("Can't do that:") {
6222 self.state.pending_craft_ack = None;
6223 if let Some(pending) = self.state.pending_worker_job_ack.take() {
6224 if let Some(w) = self
6225 .state
6226 .hired_workers
6227 .iter_mut()
6228 .find(|w| w.instance_id == pending.worker_instance_id)
6229 {
6230 w.route = pending.prev_route;
6231 w.mode = pending.prev_mode;
6232 w.step_label = pending.prev_step_label;
6233 w.last_error = pending.prev_last_error;
6234 }
6235 let reason = notice
6236 .message
6237 .strip_prefix("Can't do that:")
6238 .unwrap_or(¬ice.message)
6239 .trim();
6240 self.state.push_log(format!(
6241 "Route save failed for {}: {reason}",
6242 pending.worker_label
6243 ));
6244 }
6245 let reason = notice
6246 .message
6247 .strip_prefix("Can't do that:")
6248 .unwrap_or(¬ice.message)
6249 .trim();
6250 if reason.contains("already tilled") {
6251 if let Some(plot) = self.state.my_plot_under_player() {
6252 self.state.sell_plot_confirm = Some(plot.plot_id);
6253 self.state.sell_plot_armed_at = Some(Instant::now());
6254 }
6255 }
6256 }
6257 if notice.message.starts_with("Cast failed:") {
6258 self.state.cast_progress = None;
6259 }
6260 if notice.message.contains("slain the") {
6261 self.state.combat_target = None;
6262 self.state.combat_target_label = None;
6263 }
6264 if notice.message.contains("wants to trade") {
6266 if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
6267 let from_name = notice
6268 .message
6269 .split(" wants to trade")
6270 .next()
6271 .unwrap_or("Player")
6272 .to_string();
6273 self.state.social_chat.pending_trade =
6274 Some(crate::social::PendingTradeRequest {
6275 from_entity,
6276 from_name: from_name.clone(),
6277 });
6278 self.state.social_chat.push_system(format!(
6279 "{from_name} wants to trade — [Y] accept · [N] decline"
6280 ));
6281 self.state
6282 .social_chat
6283 .push_cue(crate::social::AudioCue::TradeOffer);
6284 }
6285 }
6286 if notice.message.starts_with("trade request declined") {
6287 self.state
6288 .social_chat
6289 .push_system(notice.message.clone());
6290 self.state
6291 .social_chat
6292 .push_cue(crate::social::AudioCue::TradeDeclined);
6293 }
6294 self.state.apply_interaction_notice(¬ice);
6295 self.state.push_log(notice.message.clone());
6296 }
6297 SessionEvent::ShopOpened(catalog) => {
6298 self.state.apply_shop_catalog(catalog);
6299 }
6300 SessionEvent::BankOpened(panel) => {
6301 self.state.apply_bank_panel(panel);
6302 }
6303 SessionEvent::StorageOpened(panel) => {
6304 self.state.apply_storage_panel(panel);
6305 }
6306 SessionEvent::MarketOpened(panel) => {
6307 self.state.apply_market_panel(panel);
6308 }
6309 SessionEvent::NpcTalkOpened(opened) => {
6310 self.state.show_npc_verb_menu = false;
6311 if self.state.npc_verb_target.is_none() {
6312 self.state.npc_verb_target = Some(opened.npc_id.clone());
6313 }
6314 let label = opened.npc_label.clone();
6315 let banner = if !opened.trade_allowed {
6316 Some("Trade is unavailable right now.".to_string())
6317 } else {
6318 None
6319 };
6320 self.state.show_npc_chat = true;
6321 self.state.npc_chat = Some(NpcChatState {
6322 npc_id: opened.npc_id,
6323 npc_label: opened.npc_label,
6324 lines: if opened.greeting.is_empty() {
6325 vec![]
6326 } else {
6327 vec![format!("{label}: {}", opened.greeting)]
6328 },
6329 input: String::new(),
6330 pending: opened.greeting.is_empty(),
6331 talk_depth: opened.talk_depth,
6332 trade_allowed: opened.trade_allowed,
6333 banner,
6334 });
6335 }
6336 SessionEvent::NpcTalkPending(_) => {
6337 if let Some(chat) = self.state.npc_chat.as_mut() {
6338 chat.pending = true;
6339 }
6340 }
6341 SessionEvent::NpcTalkReply(reply) => {
6342 if let Some(chat) = self.state.npc_chat.as_mut() {
6343 if chat.npc_id == reply.npc_id {
6344 chat.pending = false;
6345 if reply.trade_disabled {
6346 chat.trade_allowed = false;
6347 chat.banner = Some("Trade is unavailable right now.".to_string());
6348 }
6349 if reply.wind_down {
6350 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
6351 if chat.banner.is_none() {
6352 chat.banner =
6353 Some("They're wrapping up — keep it brief.".to_string());
6354 }
6355 }
6356 chat.lines
6357 .push(format!("{}: {}", chat.npc_label, reply.line));
6358 }
6359 }
6360 }
6361 SessionEvent::NpcTalkClosed(closed) => {
6362 if self
6363 .state
6364 .npc_chat
6365 .as_ref()
6366 .is_some_and(|c| c.npc_id == closed.npc_id)
6367 {
6368 self.state.show_npc_chat = false;
6369 self.state.npc_chat = None;
6370 }
6371 }
6372 SessionEvent::NpcTalkError(err) => {
6373 self.state.push_log(format!("Talk failed: {}", err.reason));
6374 if let Some(chat) = self.state.npc_chat.as_mut() {
6375 chat.pending = false;
6376 }
6377 }
6378 SessionEvent::UseResult(result) => {
6379 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
6382 *qty = qty.saturating_sub(1);
6383 if *qty == 0 {
6384 self.state.inventory.remove(&result.template_id);
6385 }
6386 }
6387 }
6388 SessionEvent::QuestOffer(offer) => {
6389 self.state.pending_quest_offer = Some(offer.clone());
6390 self.state.show_quest_offer = true;
6391 self.state
6392 .push_log(format!("Quest offered: {}", offer.title));
6393 }
6394 SessionEvent::QuestAccepted(notice) => {
6395 self.state.show_quest_offer = false;
6396 self.state.pending_quest_offer = None;
6397 self.state.push_log(notice.message);
6398 }
6399 SessionEvent::QuestWithdrawn(notice) => {
6400 self.state.show_quest_menu = false;
6401 self.state.quest_withdraw_confirm = false;
6402 self.state.push_log(notice.message);
6403 }
6404 SessionEvent::QuestStepCompleted(notice) => {
6405 self.state.push_log(notice.message);
6406 }
6407 SessionEvent::QuestCompleted(notice) => {
6408 self.state.push_log(notice.message);
6409 }
6410 SessionEvent::Disconnected { reason } => {
6411 self.state.clear_harvest_state();
6412 self.state.connected = false;
6413 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
6414 if let Some(r) = &self.state.disconnect_reason {
6415 self.state.push_log(format!("Disconnected: {r}"));
6416 } else {
6417 self.state.push_log("Disconnected from server");
6418 }
6419 }
6420 }
6421 Ok(())
6422 }
6423
6424 pub fn is_connected(&self) -> bool {
6425 self.state.connected
6426 }
6427
6428 pub fn close_overlays(&mut self) {
6429 self.state.show_stats = false;
6430 self.state.show_craft_menu = false;
6431 self.state.show_shop_menu = false;
6432 self.state.shop_catalog = None;
6433 self.state.show_npc_verb_menu = false;
6434 self.state.npc_verb_target = None;
6435 self.state.show_npc_chat = false;
6436 self.state.npc_chat = None;
6437 self.state.show_inventory_menu = false;
6438 self.state.show_loadout_menu = false;
6439 self.state.show_rotation_editor = false;
6440 self.state.rotation_editor.reset();
6441 self.state.show_rename_prompt = false;
6442 self.state.show_worker_rename = false;
6443 self.state.rename_buffer.clear();
6444 self.state.show_move_picker = false;
6445 self.state.move_picker = None;
6446 self.state.show_destroy_picker = false;
6447 self.state.destroy_confirm_pending = false;
6448 self.state.destroy_picker = None;
6449 self.state.show_quest_offer = false;
6450 self.state.pending_quest_offer = None;
6451 self.state.show_quest_menu = false;
6452 self.state.quest_withdraw_confirm = false;
6453 self.state.show_workers_menu = false;
6454 self.close_worker_give_picker();
6455 self.close_worker_give_target_picker();
6456 self.close_worker_take_picker();
6457 self.close_worker_teach_picker();
6458 self.state.worker_route_editor = None;
6459 self.state.claim_mode = None;
6460 self.state.relocate_mode = None;
6461 self.state.sell_plot_confirm = None;
6462 self.state.sell_plot_armed_at = None;
6463 self.close_farm_access_panel();
6464 if self.state.show_plant_menu {
6465 self.close_plant_menu();
6466 }
6467 }
6468
6469 pub fn back_on_esc(&mut self) -> bool {
6471 if self.state.social_chat.composer_open() {
6472 self.state.social_chat.close_composer();
6473 return true;
6474 }
6475 if self.state.player_verbs.open {
6476 self.state.player_verbs.close();
6477 return true;
6478 }
6479 if self.state.whisper_pouch_ui.open {
6480 self.state.whisper_pouch_ui.open = false;
6481 return true;
6482 }
6483 if self.state.trade_ui.panel.is_some() {
6484 self.state.trade_ui.close();
6486 return true;
6487 }
6488 if self.state.show_rename_prompt {
6489 self.cancel_rename_prompt();
6490 return true;
6491 }
6492 if self.state.show_worker_rename {
6493 self.cancel_worker_rename();
6494 return true;
6495 }
6496 if self.state.show_destroy_picker {
6497 if self.state.destroy_confirm_pending {
6498 self.cancel_destroy_confirm();
6499 } else {
6500 self.close_destroy_picker();
6501 }
6502 return true;
6503 }
6504 if self.state.claim_mode.is_some() {
6505 self.cancel_claim_mode();
6506 return true;
6507 }
6508 if self.state.relocate_mode.is_some() {
6509 self.cancel_relocate_mode();
6510 return true;
6511 }
6512 if self.state.show_plant_menu {
6513 self.close_plant_menu();
6514 return true;
6515 }
6516 if self.state.show_farm_access {
6517 self.close_farm_access_panel();
6518 return true;
6519 }
6520 if self.state.sell_plot_confirm.is_some() {
6521 self.state.sell_plot_confirm = None;
6522 self.state.sell_plot_armed_at = None;
6523 self.state.push_log("Sell cancelled");
6524 return true;
6525 }
6526 if self.state.show_move_picker {
6527 self.close_move_picker();
6528 return true;
6529 }
6530 if self.state.show_rotation_editor {
6531 match self.state.rotation_editor.mode {
6532 RotationEditorMode::List => {
6533 self.state.show_rotation_editor = false;
6534 self.state.rotation_editor.reset();
6535 }
6536 RotationEditorMode::EditLabel => {
6537 self.state.rotation_editor.label_buffer.clear();
6538 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
6539 }
6540 RotationEditorMode::PickAbility => {
6541 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
6542 }
6543 RotationEditorMode::EditSequence => {
6544 self.state.rotation_editor.draft = None;
6545 self.state.rotation_editor.mode = RotationEditorMode::List;
6546 }
6547 }
6548 return true;
6549 }
6550 if self.state.show_inventory_menu {
6551 self.close_inventory_menu();
6552 return true;
6553 }
6554 if self.state.show_craft_menu {
6555 self.close_craft_menu();
6556 return true;
6557 }
6558 if self.state.show_keychain_menu {
6559 self.close_keychain_menu();
6560 return true;
6561 }
6562 if self.state.show_quest_offer {
6563 self.quest_offer_decline();
6564 return true;
6565 }
6566 if self.state.show_shop_menu {
6567 return false;
6569 }
6570 if self.state.bank_panel.is_some() {
6571 return false;
6572 }
6573 if self.state.storage_panel.is_some() {
6574 return false;
6575 }
6576 if self.state.market_panel.is_some() {
6577 return false;
6578 }
6579 if self.state.show_npc_chat {
6580 return false;
6582 }
6583 if self.state.show_npc_verb_menu {
6584 self.state.show_npc_verb_menu = false;
6585 self.state.npc_verb_target = None;
6586 return true;
6587 }
6588 if self.state.show_quest_menu {
6589 if self.state.quest_withdraw_confirm {
6590 self.state.quest_withdraw_confirm = false;
6591 } else {
6592 self.state.show_quest_menu = false;
6593 }
6594 return true;
6595 }
6596 if self.state.worker_route_editor.is_some() {
6597 if self.re_at_root_sheet() {
6599 let reopen = self.state.attending_worker_instance_id.clone();
6600 self.close_worker_route_editor();
6601 if let Some(id) = reopen {
6602 if let Some(idx) = self
6603 .state
6604 .hired_workers
6605 .iter()
6606 .position(|w| w.instance_id == id)
6607 {
6608 self.state.workers_menu_index = idx;
6609 self.state.show_workers_menu = true;
6610 }
6611 }
6612 } else {
6613 self.re_sheet_back();
6614 }
6615 return true;
6616 }
6617 if self.state.show_worker_give_picker {
6618 self.close_worker_give_picker();
6619 return true;
6620 }
6621 if self.state.show_worker_give_target_picker {
6622 self.close_worker_give_target_picker();
6623 return true;
6624 }
6625 if self.state.show_worker_take_picker {
6626 self.close_worker_take_picker();
6627 return true;
6628 }
6629 if self.state.show_worker_teach_picker {
6630 self.close_worker_teach_picker();
6631 return true;
6632 }
6633 if self.state.show_workers_menu {
6634 self.close_workers_menu_ui();
6635 return true;
6636 }
6637 if self.state.show_loadout_menu {
6638 self.state.show_loadout_menu = false;
6639 return true;
6640 }
6641 if self.state.show_stats {
6642 self.state.show_stats = false;
6643 return true;
6644 }
6645 if self.state.show_equip_menu {
6646 self.state.show_equip_menu = false;
6647 return true;
6648 }
6649 false
6650 }
6651
6652 pub fn toggle_stats(&mut self) {
6653 self.state.show_stats = !self.state.show_stats;
6654 if self.state.show_stats {
6655 self.state.character_sheet_tab = CharacterSheetTab::Character;
6656 self.state.show_craft_menu = false;
6657 self.state.show_shop_menu = false;
6658 self.state.shop_catalog = None;
6659 self.state.show_inventory_menu = false;
6660 self.state.show_equip_menu = false;
6661 }
6662 }
6663
6664 pub fn toggle_equip_menu(&mut self) {
6665 self.state.show_equip_menu = !self.state.show_equip_menu;
6666 if self.state.show_equip_menu {
6667 self.state.show_stats = false;
6668 self.state.show_craft_menu = false;
6669 self.state.show_shop_menu = false;
6670 self.state.shop_catalog = None;
6671 self.state.show_inventory_menu = false;
6672 self.state.show_loadout_menu = false;
6673 }
6674 }
6675
6676 pub fn cycle_character_sheet_tab(&mut self) {
6677 if self.state.show_stats {
6678 self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
6679 }
6680 }
6681
6682 pub fn set_ledger_period_digit(&mut self, c: char) {
6683 if self.state.show_stats {
6684 if let Some(p) = LedgerPeriod::from_digit(c) {
6685 self.state.ledger_period = p;
6686 self.state.character_sheet_tab = CharacterSheetTab::Ledger;
6687 }
6688 }
6689 }
6690
6691 pub fn cycle_ledger_period(&mut self) {
6692 if self.state.show_stats
6693 && self.state.character_sheet_tab == CharacterSheetTab::Ledger
6694 {
6695 self.state.ledger_period = self.state.ledger_period.cycle();
6696 }
6697 }
6698
6699 pub fn open_inventory_menu(&mut self) {
6700 self.state.show_inventory_menu = true;
6701 self.state.show_craft_menu = false;
6702 self.state.show_shop_menu = false;
6703 self.state.shop_catalog = None;
6704 self.state.show_stats = false;
6705 self.state.show_move_picker = false;
6706 self.state.move_picker = None;
6707 self.state.show_destroy_picker = false;
6708 self.state.destroy_confirm_pending = false;
6709 self.state.destroy_picker = None;
6710 self.state.show_rename_prompt = false;
6711 self.state.rename_buffer.clear();
6712 self.state.inventory_filter_focused = false;
6713 self.state.clamp_inventory_indices();
6714 }
6715
6716 pub fn close_inventory_menu(&mut self) {
6717 self.state.show_inventory_menu = false;
6718 self.state.show_move_picker = false;
6719 self.state.move_picker = None;
6720 self.close_grant_picker();
6721 self.state.show_destroy_picker = false;
6722 self.state.destroy_confirm_pending = false;
6723 self.state.destroy_picker = None;
6724 self.state.show_rename_prompt = false;
6725 self.state.rename_buffer.clear();
6726 self.state.inventory_filter_focused = false;
6727 }
6728
6729 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
6730 let Some(row) = self.state.inventory_selected_row() else {
6731 anyhow::bail!("inventory empty");
6732 };
6733 if !self.state.row_is_renameable_container(&row) {
6734 anyhow::bail!("only storage containers can be renamed");
6735 }
6736 let current = row
6737 .stack
6738 .display_name
6739 .clone()
6740 .unwrap_or_else(|| row.stack.template_id.clone());
6741 self.state.rename_buffer = current;
6742 self.state.show_rename_prompt = true;
6743 self.state.show_worker_rename = false;
6744 self.state.show_move_picker = false;
6745 self.state.show_destroy_picker = false;
6746 self.state.destroy_confirm_pending = false;
6747 Ok(())
6748 }
6749
6750 pub fn cancel_rename_prompt(&mut self) {
6751 self.state.show_rename_prompt = false;
6752 self.state.rename_buffer.clear();
6753 }
6754
6755 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
6756 let name = self.state.rename_buffer.trim().to_string();
6757 if name.is_empty() {
6758 anyhow::bail!("name cannot be empty");
6759 }
6760 let Some(row) = self.state.inventory_selected_row() else {
6761 anyhow::bail!("inventory empty");
6762 };
6763 let Some(instance_id) = row.stack.item_instance_id else {
6764 anyhow::bail!("item has no instance id");
6765 };
6766 self.seq += 1;
6767 self.session
6768 .submit_intent(Intent::RenameContainer {
6769 entity_id: self.state.entity_id,
6770 item_instance_id: instance_id,
6771 location: row.from.clone(),
6772 name,
6773 seq: self.seq,
6774 })
6775 .await?;
6776 self.state.intents_sent += 1;
6777 self.state.show_rename_prompt = false;
6778 self.state.rename_buffer.clear();
6779 Ok(())
6780 }
6781
6782 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
6783 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
6784 anyhow::bail!("no worker selected");
6785 };
6786 self.state.rename_buffer = worker.label.clone();
6787 self.state.show_worker_rename = true;
6788 self.state.show_rename_prompt = false;
6789 Ok(())
6790 }
6791
6792 pub fn cancel_worker_rename(&mut self) {
6793 self.state.show_worker_rename = false;
6794 self.state.rename_buffer.clear();
6795 }
6796
6797 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
6798 let name = self.state.rename_buffer.trim().to_string();
6799 if name.is_empty() {
6800 anyhow::bail!("name cannot be empty");
6801 }
6802 if name.chars().count() > 32 {
6803 anyhow::bail!("name must be 1–32 characters");
6804 }
6805 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
6806 anyhow::bail!("no worker selected");
6807 };
6808 let worker_instance_id = worker.instance_id.clone();
6809 self.seq += 1;
6810 self.session
6811 .submit_intent(Intent::RenameHiredWorker {
6812 entity_id: self.state.entity_id,
6813 worker_instance_id: worker_instance_id.clone(),
6814 name: name.clone(),
6815 seq: self.seq,
6816 })
6817 .await?;
6818 self.state.intents_sent += 1;
6819 if let Some(w) = self
6820 .state
6821 .hired_workers
6822 .iter_mut()
6823 .find(|w| w.instance_id == worker_instance_id)
6824 {
6825 w.label = name.clone();
6826 }
6827 if let Some(ed) = self.state.worker_route_editor.as_mut() {
6828 if ed.worker_instance_id == worker_instance_id {
6829 ed.worker_label = name.clone();
6830 }
6831 }
6832 self.state.show_worker_rename = false;
6833 self.state.rename_buffer.clear();
6834 self.state.push_log(format!("Renamed worker to \"{name}\""));
6835 Ok(())
6836 }
6837
6838 pub fn toggle_inventory_menu(&mut self) {
6839 if self.state.show_inventory_menu {
6840 self.close_inventory_menu();
6841 } else {
6842 self.open_inventory_menu();
6843 }
6844 }
6845
6846 pub fn inventory_menu_move(&mut self, delta: i32) {
6848 if self.state.show_grant_picker {
6849 let Some(picker) = self.state.grant_picker.as_ref() else {
6850 return;
6851 };
6852 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6853 let filter = picker.filter.clone();
6854 let n = labels.len();
6855 if n == 0 {
6856 return;
6857 }
6858 self.state.grant_picker_index = step_filtered_index(
6859 self.state.grant_picker_index,
6860 delta,
6861 n,
6862 |i| list_label_matches(&labels[i], &filter),
6863 );
6864 return;
6865 }
6866 if self.state.show_move_picker {
6867 let Some(picker) = self.state.move_picker.as_ref() else {
6868 return;
6869 };
6870 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6871 let filter = picker.filter.clone();
6872 let n = labels.len();
6873 if n == 0 {
6874 return;
6875 }
6876 self.state.move_picker_index = step_filtered_index(
6877 self.state.move_picker_index,
6878 delta,
6879 n,
6880 |i| list_label_matches(&labels[i], &filter),
6881 );
6882 self.state.clamp_move_picker_quantity();
6883 return;
6884 }
6885 let n = self.state.inventory_selectable_rows().len();
6886 if n == 0 {
6887 return;
6888 }
6889 let idx = self.state.inventory_menu_index as i32;
6890 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6891 }
6892
6893 pub fn inventory_menu_page(&mut self, pages: i32) {
6895 if self.state.show_grant_picker {
6896 let Some(picker) = self.state.grant_picker.as_ref() else {
6897 return;
6898 };
6899 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6900 let filter = picker.filter.clone();
6901 let n = labels.len();
6902 self.state.grant_picker_index = page_filtered_index(
6903 self.state.grant_picker_index,
6904 pages,
6905 n,
6906 |i| list_label_matches(&labels[i], &filter),
6907 );
6908 return;
6909 }
6910 if self.state.show_move_picker {
6911 let Some(picker) = self.state.move_picker.as_ref() else {
6912 return;
6913 };
6914 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
6915 let filter = picker.filter.clone();
6916 let n = labels.len();
6917 self.state.move_picker_index = page_filtered_index(
6918 self.state.move_picker_index,
6919 pages,
6920 n,
6921 |i| list_label_matches(&labels[i], &filter),
6922 );
6923 self.state.clamp_move_picker_quantity();
6924 return;
6925 }
6926 let n = self.state.inventory_selectable_rows().len();
6927 self.state.inventory_menu_index =
6928 page_list_index(self.state.inventory_menu_index, pages, n);
6929 }
6930
6931 pub fn cycle_inventory_tab(&mut self, forward: bool) {
6932 if self.state.show_move_picker
6933 || self.state.show_grant_picker
6934 || self.state.show_destroy_picker
6935 || self.state.show_rename_prompt
6936 || self.state.inventory_filter_focused
6937 {
6938 return;
6939 }
6940 self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
6941 self.state.inventory_menu_index = 0;
6942 self.state.clamp_inventory_indices();
6943 }
6944
6945 pub fn focus_inventory_filter(&mut self) {
6946 if self.state.show_grant_picker {
6947 if let Some(p) = self.state.grant_picker.as_mut() {
6948 p.filter_focused = true;
6949 }
6950 return;
6951 }
6952 if self.state.show_move_picker {
6953 if let Some(p) = self.state.move_picker.as_mut() {
6954 p.filter_focused = true;
6955 }
6956 return;
6957 }
6958 self.state.inventory_filter_focused = true;
6959 }
6960
6961 pub fn set_inventory_filter(&mut self, filter: String) {
6962 self.state.inventory_filter = filter;
6963 self.state.inventory_menu_index = 0;
6964 self.state.clamp_inventory_indices();
6965 }
6966
6967 pub fn append_inventory_filter_char(&mut self, ch: char) {
6968 if ch.is_control() {
6969 return;
6970 }
6971 if self.state.show_grant_picker {
6972 if let Some(p) = self.state.grant_picker.as_mut() {
6973 if p.filter_focused {
6974 p.filter.push(ch);
6975 self.state.grant_picker_index = 0;
6976 }
6977 }
6978 return;
6979 }
6980 if self.state.show_move_picker {
6981 if let Some(p) = self.state.move_picker.as_mut() {
6982 if p.filter_focused {
6983 p.filter.push(ch);
6984 self.state.move_picker_index = 0;
6985 self.state.clamp_move_picker_quantity();
6986 }
6987 }
6988 return;
6989 }
6990 if !self.state.inventory_filter_focused {
6991 return;
6992 }
6993 self.state.inventory_filter.push(ch);
6994 self.state.inventory_menu_index = 0;
6995 self.state.clamp_inventory_indices();
6996 }
6997
6998 pub fn inventory_filter_backspace(&mut self) {
6999 if self.state.show_grant_picker {
7000 if let Some(p) = self.state.grant_picker.as_mut() {
7001 if p.filter_focused {
7002 p.filter.pop();
7003 self.state.grant_picker_index = 0;
7004 }
7005 }
7006 return;
7007 }
7008 if self.state.show_move_picker {
7009 if let Some(p) = self.state.move_picker.as_mut() {
7010 if p.filter_focused {
7011 p.filter.pop();
7012 self.state.move_picker_index = 0;
7013 self.state.clamp_move_picker_quantity();
7014 }
7015 }
7016 return;
7017 }
7018 if !self.state.inventory_filter_focused {
7019 return;
7020 }
7021 self.state.inventory_filter.pop();
7022 self.state.inventory_menu_index = 0;
7023 self.state.clamp_inventory_indices();
7024 }
7025
7026 pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
7028 if self.state.show_grant_picker {
7029 if let Some(p) = self.state.grant_picker.as_mut() {
7030 if p.filter_focused {
7031 if !p.filter.is_empty() {
7032 p.filter.clear();
7033 self.state.grant_picker_index = 0;
7034 } else {
7035 p.filter_focused = false;
7036 }
7037 return true;
7038 }
7039 if !p.filter.is_empty() {
7040 p.filter.clear();
7041 self.state.grant_picker_index = 0;
7042 return true;
7043 }
7044 }
7045 return false;
7046 }
7047 if self.state.show_move_picker {
7048 if let Some(p) = self.state.move_picker.as_mut() {
7049 if p.filter_focused {
7050 if !p.filter.is_empty() {
7051 p.filter.clear();
7052 self.state.move_picker_index = 0;
7053 self.state.clamp_move_picker_quantity();
7054 } else {
7055 p.filter_focused = false;
7056 }
7057 return true;
7058 }
7059 if !p.filter.is_empty() {
7060 p.filter.clear();
7061 self.state.move_picker_index = 0;
7062 self.state.clamp_move_picker_quantity();
7063 return true;
7064 }
7065 }
7066 return false;
7067 }
7068 if self.state.inventory_filter_focused {
7069 if !self.state.inventory_filter.is_empty() {
7070 self.state.inventory_filter.clear();
7071 self.state.inventory_menu_index = 0;
7072 self.state.clamp_inventory_indices();
7073 } else {
7074 self.state.inventory_filter_focused = false;
7075 }
7076 return true;
7077 }
7078 if !self.state.inventory_filter.is_empty() {
7079 self.state.inventory_filter.clear();
7080 self.state.inventory_menu_index = 0;
7081 self.state.clamp_inventory_indices();
7082 return true;
7083 }
7084 false
7085 }
7086
7087 pub fn craft_menu_page(&mut self, pages: i32) {
7088 let n = self.state.blueprints.len();
7089 self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
7090 self.state.clamp_craft_batch_quantity();
7091 }
7092
7093 pub fn shop_menu_page(&mut self, pages: i32) {
7094 let n = self.state.shop_list_len();
7095 self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
7096 self.state.clamp_shop_quantity();
7097 }
7098
7099 pub fn workers_menu_page(&mut self, pages: i32) {
7100 let n = self.state.hired_workers.len();
7101 self.state.workers_menu_index =
7102 page_list_index(self.state.workers_menu_index, pages, n);
7103 }
7104
7105 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
7110 if self.state.show_destroy_picker {
7111 if self.state.destroy_confirm_pending {
7112 return self.confirm_destroy_item().await;
7113 }
7114 return self.request_destroy_confirm();
7115 }
7116 if self.state.show_grant_picker {
7117 return self.confirm_grant_picker().await;
7118 }
7119 if self.state.show_move_picker {
7120 return self.confirm_move_picker().await;
7121 }
7122 let Some(row) = self.state.inventory_selected_row() else {
7123 anyhow::bail!("inventory empty");
7124 };
7125 if row.is_equip_shell {
7126 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
7127 anyhow::bail!("not a worn item");
7128 };
7129 return self.equip_worn(slot, None).await;
7130 }
7131 if row.is_chest_shell {
7132 return self.open_chest_pickup_picker();
7133 }
7134 let template_id = row.stack.template_id.clone();
7135 let instance_id = row.stack.item_instance_id;
7136 let category = self.state.inventory_item_category(&template_id);
7137 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7138
7139 if category == Some("weapon") {
7140 return self.equip_mainhand(Some(template_id)).await;
7141 }
7142 if category == Some("lodging") && on_person {
7143 if let Some(inst) = instance_id {
7144 return self.place_container(inst).await;
7145 }
7146 }
7147 if (category == Some("container") || category == Some("armor")) && on_person {
7148 if let Some(inst) = instance_id {
7149 let world_placeable = row.stack.world_placeable == Some(true)
7150 || template_id.contains("chest");
7151 if world_placeable {
7152 return self.place_container(inst).await;
7153 }
7154 if let Some(slot) = guess_body_slot(&template_id) {
7158 return self.equip_worn(slot, Some(inst)).await;
7159 }
7160 }
7161 }
7162 self.open_move_picker()
7166 }
7167
7168 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
7170 let Some(row) = self.state.inventory_selected_row() else {
7171 anyhow::bail!("inventory empty");
7172 };
7173 if row.from != flatland_protocol::InventoryLocation::Root {
7174 anyhow::bail!("select a consumable on your person");
7175 }
7176 if GameState::stack_is_item_grant(&row.stack) {
7177 return self.open_grant_target_picker();
7178 }
7179 if GameState::is_property_deed_template(&row.stack.template_id) {
7180 return self.open_move_picker();
7181 }
7182 let category = self
7183 .state
7184 .inventory_item_category(&row.stack.template_id);
7185 if category != Some("consumable") {
7186 anyhow::bail!("selected item is not consumable");
7187 }
7188 self.use_item(&row.stack.template_id).await
7189 }
7190
7191 pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
7193 let Some(row) = self.state.inventory_selected_row() else {
7194 anyhow::bail!("inventory empty");
7195 };
7196 if row.from != flatland_protocol::InventoryLocation::Root {
7197 anyhow::bail!("select a grant item on your person");
7198 }
7199 if !GameState::stack_is_item_grant(&row.stack) {
7200 anyhow::bail!("selected item does not grant onto gear");
7201 }
7202 let Some(grant_instance_id) = row.stack.item_instance_id else {
7203 anyhow::bail!("grant has no instance id");
7204 };
7205 let effect_id = GameState::grant_effect_id(&row.stack)
7206 .unwrap_or("?")
7207 .to_string();
7208 let mode = GameState::grant_mode(&row.stack).to_string();
7209 let options = self.state.grant_target_options(&row.stack);
7210 if options.is_empty() {
7211 anyhow::bail!("no valid gear to apply {effect_id} to");
7212 }
7213 let grant_label = row
7214 .stack
7215 .display_name
7216 .clone()
7217 .unwrap_or_else(|| row.stack.template_id.clone());
7218 self.state.show_grant_picker = true;
7219 self.state.grant_picker_index = 0;
7220 self.state.grant_picker = Some(GrantTargetPicker {
7221 grant_instance_id,
7222 grant_label,
7223 effect_id,
7224 mode,
7225 options,
7226 filter: String::new(),
7227 filter_focused: false,
7228 });
7229 Ok(())
7230 }
7231
7232 pub fn close_grant_picker(&mut self) {
7233 self.state.show_grant_picker = false;
7234 self.state.grant_picker = None;
7235 self.state.grant_picker_index = 0;
7236 }
7237
7238 pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
7239 let Some(picker) = self.state.grant_picker.clone() else {
7240 self.close_grant_picker();
7241 return Ok(());
7242 };
7243 let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
7244 self.close_grant_picker();
7245 return Ok(());
7246 };
7247 self.close_grant_picker();
7248 self.use_grant(picker.grant_instance_id, opt.target_instance_id)
7249 .await?;
7250 self.state.push_log(format!(
7251 "Applying {} onto {}…",
7252 picker.effect_id, opt.label
7253 ));
7254 Ok(())
7255 }
7256
7257 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
7261 let Some(row) = self.state.inventory_selected_row() else {
7262 anyhow::bail!("inventory empty");
7263 };
7264 if row.is_equip_shell {
7265 anyhow::bail!("this is a worn bag — press Enter to unequip it");
7266 }
7267 if row.is_chest_shell {
7268 return self.open_chest_pickup_picker();
7269 }
7270 let Some(instance_id) = row.stack.item_instance_id else {
7271 anyhow::bail!("item has no instance id");
7272 };
7273 let mut options = self.state.move_destinations_for(
7274 &row.from,
7275 row.from_parent_instance_id,
7276 row.stack.item_instance_id,
7277 &row.stack.template_id,
7278 );
7279 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7280 let category = self.state.inventory_item_category(&row.stack.template_id);
7281 if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
7282 if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
7283 options.insert(
7284 0,
7285 MoveOption {
7286 label: "Sell plot to crown…".into(),
7287 kind: MoveOptionKind::SellPlotToCrown { plot_id },
7288 },
7289 );
7290 }
7291 }
7292 if on_person && category == Some("consumable") {
7293 if GameState::stack_is_item_grant(&row.stack) {
7294 options.insert(
7295 0,
7296 MoveOption {
7297 label: "Apply onto gear…".into(),
7298 kind: MoveOptionKind::GrantApply,
7299 },
7300 );
7301 } else {
7302 options.insert(
7303 0,
7304 MoveOption {
7305 label: "Use (eat / drink)".into(),
7306 kind: MoveOptionKind::Use,
7307 },
7308 );
7309 }
7310 }
7311 let item_label = row
7312 .stack
7313 .display_name
7314 .clone()
7315 .unwrap_or_else(|| row.stack.template_id.clone());
7316 let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
7319 self.state.move_picker = Some(MovePicker {
7320 item_instance_id: instance_id,
7321 from: row.from,
7322 item_label,
7323 template_id: row.stack.template_id.clone(),
7324 stack_quantity: row.stack.quantity,
7325 quantity: initial_qty.max(1),
7326 options,
7327 filter: String::new(),
7328 filter_focused: false,
7329 });
7330 self.state.move_picker_index = 0;
7331 self.state.show_move_picker = true;
7332 self.state.show_destroy_picker = false;
7333 self.state.destroy_confirm_pending = false;
7334 self.state.destroy_picker = None;
7335 self.state.clamp_move_picker_quantity();
7336 Ok(())
7337 }
7338
7339 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
7341 let Some(row) = self.state.inventory_selected_row() else {
7342 anyhow::bail!("inventory empty");
7343 };
7344 if !row.is_chest_shell {
7345 anyhow::bail!("not a placed chest");
7346 }
7347 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
7348 anyhow::bail!("not a placed chest");
7349 };
7350 let Some(instance_id) = row.stack.item_instance_id else {
7351 anyhow::bail!("chest has no instance id");
7352 };
7353 let chest = self
7354 .state
7355 .placed_containers
7356 .iter()
7357 .find(|c| c.id == *container_id)
7358 .cloned()
7359 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
7360 let (px, py) = self.state.player_position();
7361 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
7362 anyhow::bail!("too far from {}", chest.display_name);
7363 }
7364 if chest.locked && !chest.accessible {
7365 anyhow::bail!(
7366 "need the matching key for {} before picking it up",
7367 chest.display_name
7368 );
7369 }
7370 let options = self.state.chest_pickup_destinations(container_id);
7371 let item_label = row
7372 .stack
7373 .display_name
7374 .clone()
7375 .unwrap_or_else(|| row.stack.template_id.clone());
7376 self.state.move_picker = Some(MovePicker {
7377 item_instance_id: instance_id,
7378 from: row.from.clone(),
7379 item_label,
7380 template_id: row.stack.template_id.clone(),
7381 stack_quantity: 1,
7382 quantity: 1,
7383 options,
7384 filter: String::new(),
7385 filter_focused: false,
7386 });
7387 self.state.move_picker_index = 0;
7388 self.state.show_move_picker = true;
7389 self.state.show_destroy_picker = false;
7390 self.state.destroy_confirm_pending = false;
7391 self.state.destroy_picker = None;
7392 Ok(())
7393 }
7394
7395 pub fn close_move_picker(&mut self) {
7396 self.state.show_move_picker = false;
7397 self.state.move_picker = None;
7398 }
7399
7400 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
7401 self.state.move_picker_adjust_quantity(delta);
7402 }
7403
7404 pub fn move_picker_set_quantity_max(&mut self) {
7405 self.state.move_picker_set_quantity_max();
7406 }
7407
7408 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
7409 self.state.destroy_picker_adjust_quantity(delta);
7410 }
7411
7412 pub fn destroy_picker_set_quantity_max(&mut self) {
7413 self.state.destroy_picker_set_quantity_max();
7414 }
7415
7416 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
7417 let Some(picker) = self.state.move_picker.clone() else {
7418 self.close_move_picker();
7419 return Ok(());
7420 };
7421 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
7422 self.close_move_picker();
7423 return Ok(());
7424 };
7425 match option.kind {
7426 MoveOptionKind::Cancel => {
7427 self.close_move_picker();
7428 }
7429 MoveOptionKind::Use => {
7430 self.close_move_picker();
7431 self.use_item(&picker.template_id).await?;
7432 }
7433 MoveOptionKind::GrantApply => {
7434 self.close_move_picker();
7435 self.open_grant_target_picker()?;
7436 }
7437 MoveOptionKind::SellPlotToCrown { plot_id } => {
7438 self.close_move_picker();
7439 self.confirm_sell_plot_to_crown(plot_id).await?;
7440 }
7441 MoveOptionKind::RelocatePlaced { container_id } => {
7442 self.close_move_picker();
7443 self.state.show_inventory_menu = false;
7444 self.begin_relocate_container(&container_id)?;
7445 }
7446 MoveOptionKind::Drop => {
7447 self.close_move_picker();
7448 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
7449 if self.state.deed_bound(&stack) {
7450 anyhow::bail!(
7451 "cannot drop a property deed — store it or trade it to another player"
7452 );
7453 }
7454 if self.state.key_drop_blocked(&stack) {
7455 anyhow::bail!("cannot drop the key while its chest is locked");
7456 }
7457 }
7458 self.drop_item(picker.item_instance_id, picker.from).await?;
7459 self.state
7460 .push_log(format!("Dropped {}", picker.item_label));
7461 }
7462 MoveOptionKind::PickupPlaced {
7463 container_id,
7464 nest_location,
7465 nest_parent_instance_id,
7466 } => {
7467 self.close_move_picker();
7468 self.pickup_container(container_id.clone()).await?;
7469 let nest_into_bag = nest_parent_instance_id.is_some()
7470 || !matches!(
7471 nest_location,
7472 flatland_protocol::InventoryLocation::Root
7473 );
7474 if nest_into_bag {
7475 self.move_item(
7476 picker.item_instance_id,
7477 flatland_protocol::InventoryLocation::Root,
7478 nest_location,
7479 nest_parent_instance_id,
7480 None,
7481 )
7482 .await?;
7483 self.state
7484 .push_log(format!("Picked up {} into bag", picker.item_label));
7485 } else {
7486 self.state
7487 .push_log(format!("Picked up {}", picker.item_label));
7488 }
7489 }
7490 MoveOptionKind::Move {
7491 location,
7492 parent_instance_id,
7493 } => {
7494 self.close_move_picker();
7495 let qty = if picker.quantity >= picker.stack_quantity {
7496 None
7497 } else {
7498 Some(picker.quantity)
7499 };
7500 self.move_item(
7501 picker.item_instance_id,
7502 picker.from,
7503 location,
7504 parent_instance_id,
7505 qty,
7506 )
7507 .await?;
7508 let moved = qty.unwrap_or(picker.stack_quantity);
7509 if moved >= picker.stack_quantity {
7510 self.state.push_log(format!("Moved {}", picker.item_label));
7511 } else {
7512 self.state.push_log(format!(
7513 "Moved {} ×{} of {}",
7514 picker.item_label, moved, picker.stack_quantity
7515 ));
7516 }
7517 }
7518 }
7519 Ok(())
7520 }
7521
7522 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
7524 let Some(row) = self.state.inventory_selected_row() else {
7525 anyhow::bail!("inventory empty");
7526 };
7527 if row.is_equip_shell {
7528 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
7529 }
7530 if row.is_chest_shell {
7531 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
7532 }
7533 let Some(inst) = row.stack.item_instance_id else {
7534 anyhow::bail!("item has no instance id");
7535 };
7536 if self.state.deed_bound(&row.stack) {
7537 anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
7538 }
7539 if self.state.key_drop_blocked(&row.stack) {
7540 anyhow::bail!("cannot drop the key while its chest is locked");
7541 }
7542 let label = row
7543 .stack
7544 .display_name
7545 .clone()
7546 .unwrap_or_else(|| row.stack.template_id.clone());
7547 self.drop_item(inst, row.from).await?;
7548 self.state.push_log(format!("Dropped {label}"));
7549 Ok(())
7550 }
7551
7552 pub async fn drop_item(
7553 &mut self,
7554 item_instance_id: uuid::Uuid,
7555 from: flatland_protocol::InventoryLocation,
7556 ) -> anyhow::Result<()> {
7557 self.seq += 1;
7558 self.session
7559 .submit_intent(Intent::DropItem {
7560 entity_id: self.state.entity_id,
7561 item_instance_id,
7562 from,
7563 seq: self.seq,
7564 })
7565 .await?;
7566 self.state.intents_sent += 1;
7567 Ok(())
7568 }
7569
7570 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
7572 let Some(row) = self.state.inventory_selected_row() else {
7573 anyhow::bail!("inventory empty");
7574 };
7575 if row.is_equip_shell {
7576 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
7577 }
7578 if row.is_chest_shell {
7579 anyhow::bail!("can't destroy a placed chest from the inventory list");
7580 }
7581 let Some(instance_id) = row.stack.item_instance_id else {
7582 anyhow::bail!("item has no instance id");
7583 };
7584 if self.state.deed_bound(&row.stack) {
7585 anyhow::bail!(
7586 "cannot destroy a property deed — store it or trade it to another player"
7587 );
7588 }
7589 if self.state.key_drop_blocked(&row.stack) {
7590 anyhow::bail!("cannot destroy the key while its chest is locked");
7591 }
7592 let item_label = row
7593 .stack
7594 .display_name
7595 .clone()
7596 .unwrap_or_else(|| row.stack.template_id.clone());
7597 self.state.destroy_picker = Some(DestroyPicker {
7598 item_instance_id: instance_id,
7599 from: row.from,
7600 item_label,
7601 stack_quantity: row.stack.quantity,
7602 quantity: row.stack.quantity,
7603 });
7604 self.state.destroy_confirm_pending = false;
7605 self.state.show_destroy_picker = true;
7606 self.state.show_move_picker = false;
7607 self.state.move_picker = None;
7608 Ok(())
7609 }
7610
7611 pub fn close_destroy_picker(&mut self) {
7612 self.state.show_destroy_picker = false;
7613 self.state.destroy_confirm_pending = false;
7614 self.state.destroy_picker = None;
7615 }
7616
7617 pub fn cancel_destroy_confirm(&mut self) {
7618 self.state.destroy_confirm_pending = false;
7619 }
7620
7621 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
7622 if self.state.destroy_picker.is_none() {
7623 self.close_destroy_picker();
7624 return Ok(());
7625 }
7626 self.state.destroy_confirm_pending = true;
7627 Ok(())
7628 }
7629
7630 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
7631 let Some(picker) = self.state.destroy_picker.clone() else {
7632 self.close_destroy_picker();
7633 return Ok(());
7634 };
7635 let qty = if picker.quantity >= picker.stack_quantity {
7636 None
7637 } else {
7638 Some(picker.quantity)
7639 };
7640 self.destroy_item(picker.item_instance_id, picker.from, qty)
7641 .await?;
7642 let destroyed = qty.unwrap_or(picker.stack_quantity);
7643 if destroyed >= picker.stack_quantity {
7644 self.state
7645 .push_log(format!("Destroyed {}", picker.item_label));
7646 } else {
7647 self.state.push_log(format!(
7648 "Destroyed {} ×{} of {}",
7649 picker.item_label, destroyed, picker.stack_quantity
7650 ));
7651 }
7652 self.close_destroy_picker();
7653 Ok(())
7654 }
7655
7656 pub async fn destroy_item(
7657 &mut self,
7658 item_instance_id: uuid::Uuid,
7659 from: flatland_protocol::InventoryLocation,
7660 quantity: Option<u32>,
7661 ) -> anyhow::Result<()> {
7662 self.seq += 1;
7663 self.session
7664 .submit_intent(Intent::DestroyItem {
7665 entity_id: self.state.entity_id,
7666 item_instance_id,
7667 from,
7668 quantity,
7669 seq: self.seq,
7670 })
7671 .await?;
7672 self.state.intents_sent += 1;
7673 Ok(())
7674 }
7675
7676 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
7678 if let Some(row) = self.state.inventory_selected_row() {
7679 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
7680 return self.toggle_placed_chest_lock(container_id).await;
7681 }
7682 }
7683 self.toggle_nearby_chest_lock().await
7684 }
7685
7686 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
7687 let chest = self
7688 .state
7689 .placed_containers
7690 .iter()
7691 .find(|c| c.id == container_id)
7692 .cloned()
7693 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
7694 let (px, py) = self.state.player_position();
7695 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
7696 anyhow::bail!("too far from {}", chest.display_name);
7697 }
7698 if !chest.accessible && chest.locked {
7699 anyhow::bail!(
7700 "need the matching key for {} (each crafted chest has its own key)",
7701 chest.display_name
7702 );
7703 }
7704 let lock = !chest.locked;
7705 self.set_container_locked(
7706 flatland_protocol::InventoryLocation::Placed {
7707 container_id: chest.id.clone(),
7708 },
7709 lock,
7710 )
7711 .await?;
7712 self.state.push_log(if lock {
7713 format!("Locked {}", chest.display_name)
7714 } else {
7715 format!("Unlocked {}", chest.display_name)
7716 });
7717 Ok(())
7718 }
7719
7720 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
7722 let chest = self
7723 .state
7724 .nearest_placed_container(CONTAINER_RANGE_M)
7725 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
7726 self.toggle_placed_chest_lock(&chest.id).await
7727 }
7728
7729 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
7730 self.equip_mainhand(None).await
7731 }
7732
7733 pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
7734 if !self.state.is_alive() {
7735 anyhow::bail!("you are dead");
7736 }
7737 self.seq += 1;
7738 self.session
7739 .submit_intent(Intent::EquipOffhand {
7740 entity_id: self.state.entity_id,
7741 template_id,
7742 instance_id: None,
7743 seq: self.seq,
7744 })
7745 .await?;
7746 self.state.intents_sent += 1;
7747 Ok(())
7748 }
7749
7750 pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
7751 self.equip_offhand(None).await
7752 }
7753
7754 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
7755 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
7756 for slot in slots {
7757 self.equip_worn(slot, None).await?;
7758 }
7759 Ok(())
7760 }
7761
7762 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
7763 let (px, py) = self.state.player_position();
7764 let nearest = self
7765 .state
7766 .placed_containers
7767 .iter()
7768 .min_by(|a, b| {
7769 let da = (a.x - px).hypot(a.y - py);
7770 let db = (b.x - px).hypot(b.y - py);
7771 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
7772 })
7773 .cloned();
7774 let Some(chest) = nearest else {
7775 anyhow::bail!("no chest nearby");
7776 };
7777 if (chest.x - px).hypot(chest.y - py) > 2.0 {
7778 anyhow::bail!("too far from chest");
7779 }
7780 self.pickup_container(chest.id).await
7781 }
7782
7783 pub async fn equip_worn(
7784 &mut self,
7785 slot: BodySlot,
7786 instance_id: Option<uuid::Uuid>,
7787 ) -> anyhow::Result<()> {
7788 self.seq += 1;
7789 self.session
7790 .submit_intent(Intent::EquipWorn {
7791 entity_id: self.state.entity_id,
7792 slot,
7793 instance_id,
7794 seq: self.seq,
7795 })
7796 .await?;
7797 self.state.intents_sent += 1;
7798 Ok(())
7799 }
7800
7801 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
7802 self.seq += 1;
7803 self.session
7804 .submit_intent(Intent::PlaceContainer {
7805 entity_id: self.state.entity_id,
7806 item_instance_id,
7807 seq: self.seq,
7808 })
7809 .await?;
7810 self.state.intents_sent += 1;
7811 Ok(())
7812 }
7813
7814 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
7815 self.seq += 1;
7816 self.session
7817 .submit_intent(Intent::PickupContainer {
7818 entity_id: self.state.entity_id,
7819 container_id,
7820 seq: self.seq,
7821 })
7822 .await?;
7823 self.state.intents_sent += 1;
7824 Ok(())
7825 }
7826
7827 pub async fn move_item(
7828 &mut self,
7829 item_instance_id: uuid::Uuid,
7830 from: flatland_protocol::InventoryLocation,
7831 to: flatland_protocol::InventoryLocation,
7832 to_parent_instance_id: Option<uuid::Uuid>,
7833 quantity: Option<u32>,
7834 ) -> anyhow::Result<()> {
7835 self.seq += 1;
7836 self.session
7837 .submit_intent(Intent::MoveItem {
7838 entity_id: self.state.entity_id,
7839 item_instance_id,
7840 from,
7841 to,
7842 to_parent_instance_id,
7843 quantity,
7844 seq: self.seq,
7845 })
7846 .await?;
7847 self.state.intents_sent += 1;
7848 Ok(())
7849 }
7850
7851 pub async fn set_container_locked(
7852 &mut self,
7853 location: flatland_protocol::InventoryLocation,
7854 locked: bool,
7855 ) -> anyhow::Result<()> {
7856 self.seq += 1;
7857 self.session
7858 .submit_intent(Intent::SetContainerLocked {
7859 entity_id: self.state.entity_id,
7860 location,
7861 locked,
7862 seq: self.seq,
7863 })
7864 .await?;
7865 self.state.intents_sent += 1;
7866 Ok(())
7867 }
7868
7869 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
7870 if !self.state.is_alive() {
7871 anyhow::bail!("you are dead");
7872 }
7873 self.seq += 1;
7874 self.session
7875 .submit_intent(Intent::Use {
7876 entity_id: self.state.entity_id,
7877 template_id: template_id.to_string(),
7878 seq: self.seq,
7879 })
7880 .await?;
7881 self.state.intents_sent += 1;
7882 Ok(())
7883 }
7884
7885 pub async fn use_grant(
7887 &mut self,
7888 grant_instance_id: uuid::Uuid,
7889 target_instance_id: uuid::Uuid,
7890 ) -> anyhow::Result<()> {
7891 if !self.state.is_alive() {
7892 anyhow::bail!("you are dead");
7893 }
7894 self.seq += 1;
7895 self.session
7896 .submit_intent(Intent::UseGrant {
7897 entity_id: self.state.entity_id,
7898 grant_instance_id,
7899 target_instance_id,
7900 seq: self.seq,
7901 })
7902 .await?;
7903 self.state.intents_sent += 1;
7904 Ok(())
7905 }
7906
7907 pub fn open_craft_menu(&mut self) {
7908 self.state.show_craft_menu = true;
7909 self.state.show_shop_menu = false;
7910 self.state.shop_catalog = None;
7911 self.state.show_stats = false;
7912 self.state.show_inventory_menu = false;
7913 if self.state.blueprints.is_empty() {
7914 self.state.craft_menu_index = 0;
7915 self.state.craft_batch_quantity = 1;
7916 return;
7917 }
7918 self.state.craft_menu_index = self
7919 .state
7920 .craft_menu_index
7921 .min(self.state.blueprints.len() - 1);
7922 if let Some(idx) = self
7923 .state
7924 .blueprints
7925 .iter()
7926 .position(|bp| self.state.can_craft_blueprint(bp))
7927 {
7928 self.state.craft_menu_index = idx;
7929 }
7930 self.state.clamp_craft_batch_quantity();
7931 }
7932
7933 pub fn close_craft_menu(&mut self) {
7934 self.state.show_craft_menu = false;
7935 }
7936
7937 pub fn toggle_keychain_menu(&mut self) {
7938 if self.state.show_keychain_menu {
7939 self.close_keychain_menu();
7940 } else {
7941 self.state.show_keychain_menu = true;
7942 self.state.show_craft_menu = false;
7943 self.state.show_shop_menu = false;
7944 self.state.show_inventory_menu = false;
7945 let n = self.state.keychain_entries().len();
7946 if n == 0 {
7947 self.state.keychain_menu_index = 0;
7948 } else {
7949 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
7950 }
7951 }
7952 }
7953
7954 pub fn close_keychain_menu(&mut self) {
7955 self.state.show_keychain_menu = false;
7956 }
7957
7958 pub fn keychain_menu_move(&mut self, delta: i32) {
7959 let n = self.state.keychain_entries().len();
7960 if n == 0 {
7961 self.state.keychain_menu_index = 0;
7962 return;
7963 }
7964 let idx = self.state.keychain_menu_index as i32 + delta;
7965 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
7966 }
7967
7968 pub fn keychain_menu_page(&mut self, pages: i32) {
7969 let n = self.state.keychain_entries().len();
7970 self.state.keychain_menu_index =
7971 page_list_index(self.state.keychain_menu_index, pages, n);
7972 }
7973
7974 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
7975 if !self.state.is_alive() {
7976 anyhow::bail!("you are dead");
7977 }
7978 let entries = self.state.keychain_entries();
7979 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
7980 anyhow::bail!("nothing selected");
7981 };
7982 let Some(instance_id) = entry.stack.item_instance_id else {
7983 anyhow::bail!("key has no instance id");
7984 };
7985 if entry.stowed {
7986 self.move_item(
7987 instance_id,
7988 flatland_protocol::InventoryLocation::Keychain,
7989 flatland_protocol::InventoryLocation::Root,
7990 None,
7991 Some(1),
7992 )
7993 .await
7994 } else {
7995 self.move_item(
7996 instance_id,
7997 flatland_protocol::InventoryLocation::Root,
7998 flatland_protocol::InventoryLocation::Keychain,
7999 None,
8000 Some(1),
8001 )
8002 .await
8003 }
8004 }
8005
8006 pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
8007 let npc_id = self
8008 .state
8009 .shop_catalog
8010 .as_ref()
8011 .map(|c| c.npc_id.clone());
8012 self.state.show_shop_menu = false;
8013 self.state.shop_catalog = None;
8014 self.state.clear_shop_trade_log();
8015 if let Some(npc_id) = npc_id {
8016 self.seq += 1;
8017 self.session
8018 .submit_intent(Intent::ShopClose {
8019 entity_id: self.state.entity_id,
8020 npc_id,
8021 seq: self.seq,
8022 })
8023 .await?;
8024 self.state.intents_sent += 1;
8025 }
8026 Ok(())
8027 }
8028
8029 pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8030 let Some(panel) = self.state.bank_panel.clone() else {
8031 return Ok(());
8032 };
8033 self.seq += 1;
8034 self.session
8035 .submit_intent(Intent::BankDeposit {
8036 entity_id: self.state.entity_id,
8037 npc_id: panel.npc_id,
8038 amount_copper,
8039 seq: self.seq,
8040 })
8041 .await?;
8042 self.state.intents_sent += 1;
8043 Ok(())
8044 }
8045
8046 pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8047 let Some(panel) = self.state.bank_panel.clone() else {
8048 return Ok(());
8049 };
8050 self.seq += 1;
8051 self.session
8052 .submit_intent(Intent::BankWithdraw {
8053 entity_id: self.state.entity_id,
8054 npc_id: panel.npc_id,
8055 amount_copper,
8056 seq: self.seq,
8057 })
8058 .await?;
8059 self.state.intents_sent += 1;
8060 Ok(())
8061 }
8062
8063 pub async fn bank_transfer(
8064 &mut self,
8065 to_character_id: Option<uuid::Uuid>,
8066 to_name: String,
8067 amount_copper: u64,
8068 ) -> anyhow::Result<()> {
8069 let Some(panel) = self.state.bank_panel.clone() else {
8070 return Ok(());
8071 };
8072 self.seq += 1;
8073 self.session
8074 .submit_intent(Intent::BankTransfer {
8075 entity_id: self.state.entity_id,
8076 npc_id: panel.npc_id,
8077 to_character_id,
8078 to_name,
8079 amount_copper,
8080 seq: self.seq,
8081 })
8082 .await?;
8083 self.state.intents_sent += 1;
8084 Ok(())
8085 }
8086
8087 pub fn bank_menu_move(&mut self, delta: i32) {
8088 let n = self.state.bank_menu_options().len();
8089 if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
8090 return;
8091 }
8092 let idx = self.state.bank_menu_index as i32;
8093 self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8094 }
8095
8096 pub fn storage_menu_move(&mut self, delta: i32) {
8097 let n = self.state.storage_menu_options().len();
8098 if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
8099 return;
8100 }
8101 let idx = self.state.storage_menu_index as i32;
8102 self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8103 }
8104
8105 pub fn storage_pick_move(&mut self, delta: i32) {
8106 let n = match &self.state.storage_ui_mode {
8107 StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
8108 StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
8109 self.state.storage_vault_options().len()
8110 }
8111 StorageUiMode::Menu
8112 | StorageUiMode::StoreAmount { .. }
8113 | StorageUiMode::TakeAmount { .. }
8114 | StorageUiMode::ShipAmount { .. } => 0,
8115 };
8116 if n == 0 {
8117 return;
8118 }
8119 match &mut self.state.storage_ui_mode {
8120 StorageUiMode::StorePick { index }
8121 | StorageUiMode::TakePick { index }
8122 | StorageUiMode::ShipPick { index, .. } => {
8123 *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8124 }
8125 StorageUiMode::Menu
8126 | StorageUiMode::StoreAmount { .. }
8127 | StorageUiMode::TakeAmount { .. }
8128 | StorageUiMode::ShipAmount { .. } => {}
8129 }
8130 }
8131
8132 pub fn storage_ui_back(&mut self) {
8133 self.state.storage_ui_mode = match &self.state.storage_ui_mode {
8134 StorageUiMode::StoreAmount { pick_index, .. } => StorageUiMode::StorePick {
8135 index: *pick_index,
8136 },
8137 StorageUiMode::TakeAmount { pick_index, .. } => StorageUiMode::TakePick {
8138 index: *pick_index,
8139 },
8140 StorageUiMode::ShipAmount {
8141 dest_building_id,
8142 dest_label,
8143 pick_index,
8144 ..
8145 } => StorageUiMode::ShipPick {
8146 dest_building_id: dest_building_id.clone(),
8147 dest_label: dest_label.clone(),
8148 index: *pick_index,
8149 },
8150 StorageUiMode::StorePick { .. }
8151 | StorageUiMode::TakePick { .. }
8152 | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
8153 StorageUiMode::Menu => StorageUiMode::Menu,
8154 };
8155 }
8156
8157 pub fn storage_amount_append_char(&mut self, c: char) {
8158 match &mut self.state.storage_ui_mode {
8159 StorageUiMode::StoreAmount { input, .. }
8160 | StorageUiMode::TakeAmount { input, .. }
8161 | StorageUiMode::ShipAmount { input, .. } => {
8162 if c.is_ascii_digit() && input.len() < 8 {
8163 input.push(c);
8164 }
8165 }
8166 _ => {}
8167 }
8168 }
8169
8170 pub fn storage_amount_backspace(&mut self) {
8171 match &mut self.state.storage_ui_mode {
8172 StorageUiMode::StoreAmount { input, .. }
8173 | StorageUiMode::TakeAmount { input, .. }
8174 | StorageUiMode::ShipAmount { input, .. } => {
8175 input.pop();
8176 }
8177 _ => {}
8178 }
8179 }
8180
8181 pub fn storage_ui_typing(&self) -> bool {
8182 matches!(
8183 self.state.storage_ui_mode,
8184 StorageUiMode::StoreAmount { .. }
8185 | StorageUiMode::TakeAmount { .. }
8186 | StorageUiMode::ShipAmount { .. }
8187 )
8188 }
8189
8190 pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
8191 match self.state.storage_ui_mode.clone() {
8192 StorageUiMode::Menu => {
8193 let index = self.state.storage_menu_index;
8194 match index {
8195 0 => {
8196 let opts = self.state.storage_store_options();
8197 if opts.is_empty() {
8198 self.state.push_log("Nothing loose to store.");
8199 return Ok(());
8200 }
8201 self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
8202 }
8203 1 => {
8204 let opts = self.state.storage_vault_options();
8205 if opts.is_empty() {
8206 self.state.push_log("Vault is empty.");
8207 return Ok(());
8208 }
8209 self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
8210 }
8211 n => {
8212 let dest = self
8213 .state
8214 .storage_panel
8215 .as_ref()
8216 .and_then(|p| p.ship_destinations.get(n - 2))
8217 .cloned();
8218 let Some(dest) = dest else {
8219 return Ok(());
8220 };
8221 let opts = self.state.storage_vault_options();
8222 if opts.is_empty() {
8223 self.state
8224 .push_log("Vault is empty — nothing to ship.");
8225 return Ok(());
8226 }
8227 self.state.storage_ui_mode = StorageUiMode::ShipPick {
8228 dest_building_id: dest.building_id,
8229 dest_label: dest.label,
8230 index: 0,
8231 };
8232 }
8233 }
8234 }
8235 StorageUiMode::StorePick { index } => {
8236 let opts = self.state.storage_store_options();
8237 let Some(opt) = opts.get(index) else {
8238 self.state.push_log("Nothing loose to store.");
8239 self.state.storage_ui_mode = StorageUiMode::Menu;
8240 return Ok(());
8241 };
8242 self.state.storage_ui_mode = StorageUiMode::StoreAmount {
8243 pick_index: index,
8244 item_instance_id: opt.item_instance_id,
8245 label: opt.label.clone(),
8246 max_qty: opt.quantity.max(1),
8247 input: String::new(),
8248 };
8249 }
8250 StorageUiMode::TakePick { index } => {
8251 let opts = self.state.storage_vault_options();
8252 let Some(opt) = opts.get(index) else {
8253 self.state.push_log("Vault is empty.");
8254 self.state.storage_ui_mode = StorageUiMode::Menu;
8255 return Ok(());
8256 };
8257 self.state.storage_ui_mode = StorageUiMode::TakeAmount {
8258 pick_index: index,
8259 item_instance_id: opt.item_instance_id,
8260 label: opt.label.clone(),
8261 max_qty: opt.quantity.max(1),
8262 input: String::new(),
8263 };
8264 }
8265 StorageUiMode::ShipPick {
8266 dest_building_id,
8267 dest_label,
8268 index,
8269 } => {
8270 let opts = self.state.storage_vault_options();
8271 let Some(opt) = opts.get(index) else {
8272 self.state
8273 .push_log("Vault is empty — nothing to ship.");
8274 self.state.storage_ui_mode = StorageUiMode::Menu;
8275 return Ok(());
8276 };
8277 self.state.storage_ui_mode = StorageUiMode::ShipAmount {
8278 dest_building_id,
8279 dest_label,
8280 pick_index: index,
8281 item_instance_id: opt.item_instance_id,
8282 label: opt.label.clone(),
8283 max_qty: opt.quantity.max(1),
8284 input: String::new(),
8285 };
8286 }
8287 StorageUiMode::StoreAmount {
8288 item_instance_id,
8289 max_qty,
8290 input,
8291 ..
8292 } => {
8293 let Some(qty) = parse_storage_quantity(&input) else {
8294 self.state
8295 .push_log("Enter a quantity (blank or 0 = all).");
8296 return Ok(());
8297 };
8298 let qty = qty.map(|n| n.min(max_qty).max(1));
8299 self.storage_store(item_instance_id, qty).await?;
8300 self.state.storage_ui_mode = StorageUiMode::Menu;
8301 }
8302 StorageUiMode::TakeAmount {
8303 item_instance_id,
8304 max_qty,
8305 input,
8306 ..
8307 } => {
8308 let Some(qty) = parse_storage_quantity(&input) else {
8309 self.state
8310 .push_log("Enter a quantity (blank or 0 = all).");
8311 return Ok(());
8312 };
8313 let qty = qty.map(|n| n.min(max_qty).max(1));
8314 self.storage_take(item_instance_id, qty).await?;
8315 self.state.storage_ui_mode = StorageUiMode::Menu;
8316 }
8317 StorageUiMode::ShipAmount {
8318 dest_building_id,
8319 item_instance_id,
8320 max_qty,
8321 input,
8322 ..
8323 } => {
8324 let Some(qty) = parse_storage_quantity(&input) else {
8325 self.state
8326 .push_log("Enter a quantity (blank or 0 = all).");
8327 return Ok(());
8328 };
8329 let qty = qty.map(|n| n.min(max_qty).max(1));
8330 self.storage_ship(dest_building_id, item_instance_id, qty)
8331 .await?;
8332 self.state.storage_ui_mode = StorageUiMode::Menu;
8333 }
8334 }
8335 Ok(())
8336 }
8337
8338 pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
8339 match self.state.bank_ui_mode.clone() {
8340 BankUiMode::Menu => {
8341 let choice = self
8342 .state
8343 .bank_menu_options()
8344 .get(self.state.bank_menu_index)
8345 .copied()
8346 .unwrap_or("Deposit…");
8347 match choice {
8348 "Withdraw…" => {
8349 self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
8350 input: String::new(),
8351 };
8352 }
8353 "Deposit all" => self.bank_deposit(0).await?,
8354 "Withdraw all" => self.bank_withdraw(0).await?,
8355 "Transfer…" => {
8356 self.state.bank_ui_mode = BankUiMode::TransferName {
8357 input: String::new(),
8358 };
8359 }
8360 _ => {
8361 self.state.bank_ui_mode = BankUiMode::DepositAmount {
8362 input: String::new(),
8363 };
8364 }
8365 }
8366 }
8367 BankUiMode::DepositAmount { input } => {
8368 let Some(amount) = parse_bank_copper_amount(&input) else {
8369 self.state
8370 .push_log("Enter a copper amount (blank or 0 = everything on person).");
8371 return Ok(());
8372 };
8373 self.bank_deposit(amount).await?;
8374 self.state.bank_ui_mode = BankUiMode::Menu;
8375 }
8376 BankUiMode::WithdrawAmount { input } => {
8377 let Some(amount) = parse_bank_copper_amount(&input) else {
8378 self.state
8379 .push_log("Enter a copper amount (blank or 0 = full ledger).");
8380 return Ok(());
8381 };
8382 self.bank_withdraw(amount).await?;
8383 self.state.bank_ui_mode = BankUiMode::Menu;
8384 }
8385 BankUiMode::TransferName { input } => {
8386 let name = input.trim().to_string();
8387 if name.is_empty() {
8388 self.state.push_log("Enter the recipient character name.");
8389 return Ok(());
8390 }
8391 self.state.bank_ui_mode = BankUiMode::TransferAmount {
8392 to_name: name,
8393 input: String::new(),
8394 };
8395 }
8396 BankUiMode::TransferAmount { to_name, input } => {
8397 let amount: u64 = match input.trim().parse() {
8398 Ok(v) if v > 0 => v,
8399 _ => {
8400 self.state
8401 .push_log("Enter a positive copper amount to transfer.");
8402 return Ok(());
8403 }
8404 };
8405 self.bank_transfer(None, to_name, amount).await?;
8406 self.state.bank_ui_mode = BankUiMode::Menu;
8407 }
8408 }
8409 Ok(())
8410 }
8411
8412 pub fn bank_transfer_back(&mut self) {
8413 match &self.state.bank_ui_mode {
8414 BankUiMode::TransferAmount { to_name, .. } => {
8415 self.state.bank_ui_mode = BankUiMode::TransferName {
8416 input: to_name.clone(),
8417 };
8418 }
8419 BankUiMode::TransferName { .. }
8420 | BankUiMode::DepositAmount { .. }
8421 | BankUiMode::WithdrawAmount { .. } => {
8422 self.state.bank_ui_mode = BankUiMode::Menu;
8423 }
8424 BankUiMode::Menu => {}
8425 }
8426 }
8427
8428 pub fn bank_transfer_append_char(&mut self, c: char) {
8429 match &mut self.state.bank_ui_mode {
8430 BankUiMode::TransferName { input } => {
8431 if input.len() < 32 && !c.is_control() {
8432 input.push(c);
8433 }
8434 }
8435 BankUiMode::DepositAmount { input }
8436 | BankUiMode::WithdrawAmount { input }
8437 | BankUiMode::TransferAmount { input, .. } => {
8438 if c.is_ascii_digit() && input.len() < 12 {
8439 input.push(c);
8440 }
8441 }
8442 BankUiMode::Menu => {}
8443 }
8444 }
8445
8446 pub fn bank_transfer_backspace(&mut self) {
8447 match &mut self.state.bank_ui_mode {
8448 BankUiMode::TransferName { input }
8449 | BankUiMode::DepositAmount { input }
8450 | BankUiMode::WithdrawAmount { input }
8451 | BankUiMode::TransferAmount { input, .. } => {
8452 input.pop();
8453 }
8454 BankUiMode::Menu => {}
8455 }
8456 }
8457
8458 pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
8459 let npc_id = self
8460 .state
8461 .bank_panel
8462 .as_ref()
8463 .map(|p| p.npc_id.clone());
8464 self.state.clear_bank_panel();
8465 if let Some(npc_id) = npc_id {
8466 self.seq += 1;
8467 self.session
8468 .submit_intent(Intent::BankClose {
8469 entity_id: self.state.entity_id,
8470 npc_id,
8471 seq: self.seq,
8472 })
8473 .await?;
8474 self.state.intents_sent += 1;
8475 }
8476 Ok(())
8477 }
8478
8479 pub async fn storage_store(
8480 &mut self,
8481 item_instance_id: uuid::Uuid,
8482 quantity: Option<u32>,
8483 ) -> anyhow::Result<()> {
8484 let Some(panel) = self.state.storage_panel.clone() else {
8485 return Ok(());
8486 };
8487 self.seq += 1;
8488 self.session
8489 .submit_intent(Intent::StorageStore {
8490 entity_id: self.state.entity_id,
8491 npc_id: panel.npc_id,
8492 item_instance_id,
8493 quantity,
8494 seq: self.seq,
8495 })
8496 .await?;
8497 self.state.intents_sent += 1;
8498 Ok(())
8499 }
8500
8501 pub async fn storage_take(
8502 &mut self,
8503 item_instance_id: uuid::Uuid,
8504 quantity: Option<u32>,
8505 ) -> anyhow::Result<()> {
8506 let Some(panel) = self.state.storage_panel.clone() else {
8507 return Ok(());
8508 };
8509 self.seq += 1;
8510 self.session
8511 .submit_intent(Intent::StorageTake {
8512 entity_id: self.state.entity_id,
8513 npc_id: panel.npc_id,
8514 item_instance_id,
8515 quantity,
8516 seq: self.seq,
8517 })
8518 .await?;
8519 self.state.intents_sent += 1;
8520 Ok(())
8521 }
8522
8523 pub async fn storage_ship(
8524 &mut self,
8525 dest_building_id: String,
8526 item_instance_id: uuid::Uuid,
8527 quantity: Option<u32>,
8528 ) -> anyhow::Result<()> {
8529 let Some(panel) = self.state.storage_panel.clone() else {
8530 return Ok(());
8531 };
8532 self.seq += 1;
8533 self.session
8534 .submit_intent(Intent::StorageShip {
8535 entity_id: self.state.entity_id,
8536 npc_id: panel.npc_id,
8537 dest_building_id,
8538 item_instance_id,
8539 quantity,
8540 seq: self.seq,
8541 })
8542 .await?;
8543 self.state.intents_sent += 1;
8544 Ok(())
8545 }
8546
8547 pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
8548 let npc_id = self
8549 .state
8550 .storage_panel
8551 .as_ref()
8552 .map(|p| p.npc_id.clone());
8553 self.state.clear_storage_panel();
8554 if let Some(npc_id) = npc_id {
8555 self.seq += 1;
8556 self.session
8557 .submit_intent(Intent::StorageClose {
8558 entity_id: self.state.entity_id,
8559 npc_id,
8560 seq: self.seq,
8561 })
8562 .await?;
8563 self.state.intents_sent += 1;
8564 }
8565 Ok(())
8566 }
8567
8568 pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
8569 let npc_id = self
8570 .state
8571 .market_panel
8572 .as_ref()
8573 .map(|p| p.npc_id.clone());
8574 self.state.clear_market_panel();
8575 if let Some(npc_id) = npc_id {
8576 self.seq += 1;
8577 self.session
8578 .submit_intent(Intent::MarketClose {
8579 entity_id: self.state.entity_id,
8580 npc_id,
8581 seq: self.seq,
8582 })
8583 .await?;
8584 self.state.intents_sent += 1;
8585 }
8586 Ok(())
8587 }
8588
8589 pub fn market_move_selection(&mut self, delta: i32) {
8590 let indices = self.state.market_filtered_listing_indices();
8591 let n = indices.len();
8592 if n == 0 {
8593 self.state.market_menu_index = 0;
8594 return;
8595 }
8596 let cur = self.state.market_menu_index as i32;
8597 self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
8598 }
8599
8600 pub fn market_page_selection(&mut self, pages: i32) {
8601 let indices = self.state.market_filtered_listing_indices();
8602 let n = indices.len();
8603 if n == 0 {
8604 self.state.market_menu_index = 0;
8605 return;
8606 }
8607 self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
8608 }
8609
8610 pub fn market_list_page(&mut self, pages: i32) {
8611 match &self.state.market_ui_mode {
8612 MarketUiMode::ListSource { index } => {
8613 let n = self.state.market_list_source_options().len();
8614 if n == 0 {
8615 return;
8616 }
8617 let next = page_list_index(*index, pages, n);
8618 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
8619 }
8620 MarketUiMode::ListPick { source, index } => {
8621 let opts = self.state.market_list_item_options(source);
8622 let n = opts.len();
8623 if n == 0 {
8624 return;
8625 }
8626 let next = page_list_index(*index, pages, n);
8627 self.state.market_ui_mode = MarketUiMode::ListPick {
8628 source: source.clone(),
8629 index: next,
8630 };
8631 }
8632 _ => {}
8633 }
8634 }
8635
8636 pub fn market_cycle_category(&mut self, delta: i32) {
8637 let groups = self.state.market_available_category_groups();
8638 let mut labels: Vec<Option<&'static str>> = vec![None];
8640 labels.extend(groups.into_iter().map(Some));
8641 let n = labels.len() as i32;
8642 let cur = labels
8643 .iter()
8644 .position(|g| *g == self.state.market_category_filter)
8645 .unwrap_or(0) as i32;
8646 let next = (cur + delta).rem_euclid(n) as usize;
8647 self.state.market_category_filter = labels[next];
8648 self.state.market_menu_index = 0;
8649 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8650 let source = source.clone();
8651 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8652 }
8653 }
8654
8655 pub fn focus_market_filter(&mut self) {
8656 self.state.market_filter_focused = true;
8657 }
8658
8659 pub fn append_market_filter_char(&mut self, ch: char) {
8660 if !self.state.market_filter_focused {
8661 return;
8662 }
8663 if ch.is_control() {
8664 return;
8665 }
8666 if self.state.market_filter.len() < 48 {
8667 self.state.market_filter.push(ch);
8668 self.state.market_menu_index = 0;
8669 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8670 let source = source.clone();
8671 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8672 }
8673 }
8674 }
8675
8676 pub fn market_filter_backspace(&mut self) {
8677 if !self.state.market_filter_focused {
8678 return;
8679 }
8680 self.state.market_filter.pop();
8681 self.state.market_menu_index = 0;
8682 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8683 let source = source.clone();
8684 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8685 }
8686 }
8687
8688 pub fn clear_or_blur_market_filter(&mut self) -> bool {
8690 if self.state.market_filter_focused {
8691 if !self.state.market_filter.is_empty() {
8692 self.state.market_filter.clear();
8693 self.state.market_menu_index = 0;
8694 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8695 let source = source.clone();
8696 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8697 }
8698 return true;
8699 }
8700 self.state.market_filter_focused = false;
8701 return true;
8702 }
8703 if !self.state.market_filter.is_empty() {
8704 self.state.market_filter.clear();
8705 self.state.market_menu_index = 0;
8706 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
8707 let source = source.clone();
8708 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8709 }
8710 return true;
8711 }
8712 false
8713 }
8714
8715 pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
8716 if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
8717 return self.market_confirm_buy(listing_id, qty).await;
8718 }
8719 let Some(panel) = self.state.market_panel.clone() else {
8720 return Ok(());
8721 };
8722 let indices = self.state.market_filtered_listing_indices();
8723 let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
8724 return Ok(());
8725 };
8726 let Some(listing) = panel.listings.get(raw_idx) else {
8727 return Ok(());
8728 };
8729 if listing.mine {
8730 self.seq += 1;
8731 self.session
8732 .submit_intent(Intent::MarketDelist {
8733 entity_id: self.state.entity_id,
8734 npc_id: panel.npc_id.clone(),
8735 listing_id: listing.listing_id,
8736 dest: flatland_protocol::GoodsLocation::Person,
8737 seq: self.seq,
8738 })
8739 .await?;
8740 self.state.intents_sent += 1;
8741 return Ok(());
8742 }
8743 let qty = 1u32.min(listing.quantity).max(1);
8744 let line = listing.unit_price_copper.saturating_mul(qty as u64);
8745 self.state.market_buy_confirm = Some((
8746 listing.listing_id,
8747 qty,
8748 listing.unit_price_copper,
8749 line,
8750 listing.display_name.clone(),
8751 ));
8752 Ok(())
8753 }
8754
8755 pub async fn market_confirm_buy(
8756 &mut self,
8757 listing_id: uuid::Uuid,
8758 quantity: u32,
8759 ) -> anyhow::Result<()> {
8760 let Some(panel) = self.state.market_panel.clone() else {
8761 self.state.market_buy_confirm = None;
8762 return Ok(());
8763 };
8764 self.state.market_buy_confirm = None;
8765 self.seq += 1;
8766 self.session
8767 .submit_intent(Intent::MarketBuy {
8768 entity_id: self.state.entity_id,
8769 npc_id: panel.npc_id,
8770 listing_id,
8771 quantity,
8772 dest: flatland_protocol::GoodsLocation::Person,
8773 seq: self.seq,
8774 })
8775 .await?;
8776 self.state.intents_sent += 1;
8777 Ok(())
8778 }
8779
8780 pub fn market_begin_list(&mut self) {
8782 if self.state.market_panel.is_none() {
8783 return;
8784 }
8785 let sources = self.state.market_list_source_options();
8786 if sources.is_empty() {
8787 self.state.push_log("Nothing to list from.");
8788 return;
8789 }
8790 if sources.len() == 1 {
8792 let (source, _) = sources[0].clone();
8793 let opts = self.state.market_list_item_options(&source);
8794 if opts.is_empty() {
8795 self.state.push_log("Nothing loose to list.");
8796 return;
8797 }
8798 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8799 self.state.market_buy_confirm = None;
8800 return;
8801 }
8802 self.state.market_buy_confirm = None;
8803 self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
8804 }
8805
8806 pub fn market_ui_back(&mut self) {
8807 self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
8808 MarketUiMode::Browse => MarketUiMode::Browse,
8809 MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
8810 MarketUiMode::ListPick { .. } => {
8811 if self.state.market_list_source_options().len() <= 1 {
8812 MarketUiMode::Browse
8813 } else {
8814 MarketUiMode::ListSource { index: 0 }
8815 }
8816 }
8817 MarketUiMode::ListAmount {
8818 source,
8819 pick_index,
8820 ..
8821 } => MarketUiMode::ListPick {
8822 source,
8823 index: pick_index,
8824 },
8825 MarketUiMode::ListPrice {
8826 source,
8827 item_instance_id,
8828 label,
8829 max_qty,
8830 quantity,
8831 ..
8832 } => {
8833 let input = quantity
8834 .map(|q| q.to_string())
8835 .unwrap_or_default();
8836 MarketUiMode::ListAmount {
8837 source,
8838 pick_index: 0,
8839 item_instance_id,
8840 label,
8841 max_qty,
8842 input,
8843 }
8844 }
8845 };
8846 }
8847
8848 pub fn market_list_move(&mut self, delta: i32) {
8849 match &self.state.market_ui_mode {
8850 MarketUiMode::ListSource { index } => {
8851 let n = self.state.market_list_source_options().len();
8852 if n == 0 {
8853 return;
8854 }
8855 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8856 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
8857 }
8858 MarketUiMode::ListPick { source, index } => {
8859 let opts = self.state.market_list_item_options(source);
8860 let n = opts.len();
8861 if n == 0 {
8862 return;
8863 }
8864 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8865 self.state.market_ui_mode = MarketUiMode::ListPick {
8866 source: source.clone(),
8867 index: next,
8868 };
8869 }
8870 _ => {}
8871 }
8872 }
8873
8874 pub fn market_list_amount_append_char(&mut self, c: char) {
8875 if !c.is_ascii_digit() {
8876 return;
8877 }
8878 match &mut self.state.market_ui_mode {
8879 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
8880 if input.len() < 12 {
8881 input.push(c);
8882 }
8883 }
8884 _ => {}
8885 }
8886 }
8887
8888 pub fn market_list_amount_backspace(&mut self) {
8889 match &mut self.state.market_ui_mode {
8890 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
8891 input.pop();
8892 }
8893 _ => {}
8894 }
8895 }
8896
8897 pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
8898 match self.state.market_ui_mode.clone() {
8899 MarketUiMode::Browse => Ok(()),
8900 MarketUiMode::ListSource { index } => {
8901 let sources = self.state.market_list_source_options();
8902 let Some((source, _)) = sources.get(index).cloned() else {
8903 return Ok(());
8904 };
8905 let opts = self.state.market_list_item_options(&source);
8906 if opts.is_empty() {
8907 self.state.push_log("Nothing to list from that source.");
8908 return Ok(());
8909 }
8910 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
8911 Ok(())
8912 }
8913 MarketUiMode::ListPick { source, index } => {
8914 let opts = self.state.market_list_item_options(&source);
8915 let Some(opt) = opts.get(index) else {
8916 self.state.push_log("Nothing to list.");
8917 self.state.market_ui_mode = MarketUiMode::Browse;
8918 return Ok(());
8919 };
8920 self.state.market_ui_mode = MarketUiMode::ListAmount {
8921 source,
8922 pick_index: index,
8923 item_instance_id: opt.item_instance_id,
8924 label: opt.label.clone(),
8925 max_qty: opt.quantity.max(1),
8926 input: String::new(),
8927 };
8928 Ok(())
8929 }
8930 MarketUiMode::ListAmount {
8931 source,
8932 item_instance_id,
8933 label,
8934 max_qty,
8935 input,
8936 ..
8937 } => {
8938 let Some(qty_opt) = parse_storage_quantity(&input) else {
8939 self.state.push_log("Enter a quantity (blank = all).");
8940 return Ok(());
8941 };
8942 if let Some(q) = qty_opt {
8943 if q > max_qty {
8944 self.state
8945 .push_log(format!("Only {max_qty} available."));
8946 return Ok(());
8947 }
8948 }
8949 self.state.market_ui_mode = MarketUiMode::ListPrice {
8950 source,
8951 item_instance_id,
8952 label,
8953 quantity: qty_opt,
8954 max_qty,
8955 input: String::new(),
8956 };
8957 Ok(())
8958 }
8959 MarketUiMode::ListPrice {
8960 source,
8961 item_instance_id,
8962 label,
8963 quantity,
8964 input,
8965 ..
8966 } => {
8967 let price = input.trim().parse::<u64>().unwrap_or(0);
8968 if price == 0 {
8969 self.state.push_log("Enter a unit price of at least 1 copper.");
8970 return Ok(());
8971 }
8972 let Some(panel) = self.state.market_panel.clone() else {
8973 self.state.market_ui_mode = MarketUiMode::Browse;
8974 return Ok(());
8975 };
8976 let goods = match source {
8977 MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
8978 MarketListSourceKind::TownStorage { building_id } => {
8979 flatland_protocol::GoodsLocation::TownStorage { building_id }
8980 }
8981 };
8982 self.seq += 1;
8983 self.session
8984 .submit_intent(Intent::MarketList {
8985 entity_id: self.state.entity_id,
8986 npc_id: panel.npc_id,
8987 source: goods,
8988 item_instance_id,
8989 quantity,
8990 unit_price_copper: price,
8991 seq: self.seq,
8992 })
8993 .await?;
8994 self.state.intents_sent += 1;
8995 self.state
8996 .push_log(format!("Listing {label} @ {price} cp…"));
8997 self.state.market_ui_mode = MarketUiMode::Browse;
8998 Ok(())
8999 }
9000 }
9001 }
9002
9003 pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
9005 let return_to_verbs = self.state.npc_verb_target.is_some();
9006 self.close_shop_menu().await?;
9007 if return_to_verbs {
9008 self.state.show_npc_verb_menu = true;
9009 }
9010 Ok(())
9011 }
9012
9013 pub fn shop_tab_toggle(&mut self) {
9014 self.state.shop_tab = match self.state.shop_tab {
9015 ShopTab::Buy => ShopTab::Sell,
9016 ShopTab::Sell => ShopTab::Buy,
9017 };
9018 self.state.shop_menu_index = 0;
9019 if self.state.shop_tab == ShopTab::Sell {
9020 self.state.shop_quantity_set_max();
9021 }
9022 self.state.clamp_shop_selection();
9023 }
9024
9025 pub fn shop_menu_move(&mut self, delta: i32) {
9026 self.state.shop_menu_move(delta);
9027 }
9028
9029 pub fn shop_quantity_adjust(&mut self, delta: i32) {
9030 self.state.shop_quantity_adjust(delta);
9031 }
9032
9033 pub fn shop_quantity_set_max(&mut self) {
9034 self.state.shop_quantity_set_max();
9035 }
9036
9037 pub fn toggle_quest_menu(&mut self) {
9038 self.state.show_quest_menu = !self.state.show_quest_menu;
9039 if self.state.show_quest_menu {
9040 self.state.quest_menu_index = 0;
9041 self.state.quest_withdraw_confirm = false;
9042 self.state.show_workers_menu = false;
9043 }
9044 }
9045
9046 pub fn toggle_workers_menu(&mut self) {
9047 if self.state.show_workers_menu {
9048 self.close_workers_menu_ui();
9049 } else {
9050 self.state.show_workers_menu = true;
9051 self.state.workers_menu_index = 0;
9052 self.state.show_quest_menu = false;
9053 self.close_worker_give_picker();
9054 self.close_worker_give_target_picker();
9055 self.close_worker_take_picker();
9056 self.close_worker_teach_picker();
9057 self.cancel_worker_rename();
9058 }
9059 }
9060
9061 pub fn close_workers_menu_ui(&mut self) {
9063 self.state.show_workers_menu = false;
9064 self.close_worker_give_picker();
9065 self.close_worker_give_target_picker();
9066 self.close_worker_take_picker();
9067 self.close_worker_teach_picker();
9068 self.cancel_worker_rename();
9069 }
9070
9071 pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
9073 let Some(idx) = self
9074 .state
9075 .hired_workers
9076 .iter()
9077 .position(|w| w.instance_id == instance_id)
9078 else {
9079 anyhow::bail!("worker not found");
9080 };
9081 let label = self.state.hired_workers[idx].label.clone();
9082 self.state.show_workers_menu = true;
9083 self.state.workers_menu_index = idx;
9084 self.state.show_quest_menu = false;
9085 self.close_worker_give_picker();
9086 self.close_worker_give_target_picker();
9087 self.close_worker_take_picker();
9088 self.close_worker_teach_picker();
9089 self.cancel_worker_rename();
9090 self.set_worker_attending(instance_id, true).await?;
9091 self.state
9092 .push_log(format!("Managing {label} — job paused while menu is open"));
9093 Ok(())
9094 }
9095
9096 pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
9098 self.close_workers_menu_ui();
9099 self.release_worker_attend().await
9100 }
9101
9102 async fn set_worker_attending(
9103 &mut self,
9104 instance_id: &str,
9105 attending: bool,
9106 ) -> anyhow::Result<()> {
9107 if attending {
9108 if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9109 return Ok(());
9110 }
9111 if let Some(prev) = self.state.attending_worker_instance_id.clone() {
9113 if prev != instance_id {
9114 self.send_attend_hired_worker(&prev, false).await?;
9115 }
9116 }
9117 self.send_attend_hired_worker(instance_id, true).await?;
9118 self.state.attending_worker_instance_id = Some(instance_id.to_string());
9119 } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9120 self.send_attend_hired_worker(instance_id, false).await?;
9121 self.state.attending_worker_instance_id = None;
9122 }
9123 Ok(())
9124 }
9125
9126 pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
9127 let Some(id) = self.state.attending_worker_instance_id.take() else {
9128 return Ok(());
9129 };
9130 self.send_attend_hired_worker(&id, false).await
9131 }
9132
9133 async fn send_attend_hired_worker(
9134 &mut self,
9135 worker_instance_id: &str,
9136 attending: bool,
9137 ) -> anyhow::Result<()> {
9138 self.seq += 1;
9139 self.session
9140 .submit_intent(Intent::AttendHiredWorker {
9141 entity_id: self.state.entity_id,
9142 worker_instance_id: worker_instance_id.to_string(),
9143 attending,
9144 seq: self.seq,
9145 })
9146 .await?;
9147 self.state.intents_sent += 1;
9148 Ok(())
9149 }
9150
9151 pub fn workers_menu_move(&mut self, delta: i32) {
9152 let n = self.state.hired_workers.len();
9153 if n == 0 {
9154 return;
9155 }
9156 let idx = self.state.workers_menu_index as i32;
9157 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9158 }
9159
9160 pub fn toggle_workers_menu_compact(&mut self) {
9161 self.state.workers_menu_compact = !self.state.workers_menu_compact;
9162 let mut cfg = crate::client_config::ClientConfig::load();
9163 let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
9164 }
9165
9166 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
9167 let Some(worker) = self
9168 .state
9169 .hired_workers
9170 .get(self.state.workers_menu_index)
9171 .cloned()
9172 else {
9173 anyhow::bail!("no worker selected");
9174 };
9175 self.seq += 1;
9176 self.session
9177 .submit_intent(Intent::DismissWorker {
9178 entity_id: self.state.entity_id,
9179 worker_instance_id: worker.instance_id.clone(),
9180 seq: self.seq,
9181 })
9182 .await?;
9183 self.state.intents_sent += 1;
9184 self.state
9185 .hired_workers
9186 .retain(|w| w.instance_id != worker.instance_id);
9187 if self.state.workers_menu_index >= self.state.hired_workers.len() {
9188 self.state.workers_menu_index = self
9189 .state
9190 .hired_workers
9191 .len()
9192 .saturating_sub(1);
9193 }
9194 self.state.push_log(format!("Dismissed {}", worker.label));
9195 Ok(())
9196 }
9197
9198 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
9199 let Some(worker) = self
9200 .state
9201 .hired_workers
9202 .get(self.state.workers_menu_index)
9203 .cloned()
9204 else {
9205 anyhow::bail!("no worker selected");
9206 };
9207 let mode = match worker.mode {
9208 flatland_protocol::WorkerModeView::Companion => "job_loop",
9209 flatland_protocol::WorkerModeView::JobLoop => "idle",
9210 flatland_protocol::WorkerModeView::Idle => "companion",
9211 };
9212 self.seq += 1;
9213 self.session
9214 .submit_intent(Intent::SetWorkerMode {
9215 entity_id: self.state.entity_id,
9216 worker_instance_id: worker.instance_id,
9217 mode: mode.into(),
9218 seq: self.seq,
9219 })
9220 .await?;
9221 self.state.intents_sent += 1;
9222 Ok(())
9223 }
9224
9225 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
9226 if self.state.hired_workers.is_empty() {
9227 return self.hire_worker_laborer().await;
9228 }
9229 self.workers_toggle_mode_selected().await
9230 }
9231
9232 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
9235 let row = self
9236 .state
9237 .inventory_selected_row()
9238 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
9239 .clone();
9240 if row.from != flatland_protocol::InventoryLocation::Root {
9241 anyhow::bail!("select a carried item to give");
9242 }
9243 let Some(instance_id) = row.stack.item_instance_id else {
9244 anyhow::bail!("that stack can't be given");
9245 };
9246 let options = self.nearby_worker_give_targets();
9247 if options.is_empty() {
9248 anyhow::bail!(
9249 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
9250 );
9251 }
9252 let item_label = row
9253 .stack
9254 .display_name
9255 .as_deref()
9256 .unwrap_or(&row.stack.template_id)
9257 .to_string();
9258 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
9259 item_instance_id: instance_id,
9260 item_label,
9261 quantity: None,
9262 options,
9263 });
9264 self.state.worker_give_target_picker_index = 0;
9265 self.state.show_worker_give_target_picker = true;
9266 self.state.show_inventory_menu = false;
9268 Ok(())
9269 }
9270
9271 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
9273 let (px, py, _) = self.state.player_position_with_z();
9274 let mut options: Vec<WorkerGiveTargetOption> = self
9275 .state
9276 .hired_workers
9277 .iter()
9278 .filter_map(|w| {
9279 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
9280 if dist > WORKER_GIVE_RANGE_M {
9281 return None;
9282 }
9283 Some(WorkerGiveTargetOption {
9284 instance_id: w.instance_id.clone(),
9285 label: w.label.clone(),
9286 distance_m: dist,
9287 })
9288 })
9289 .collect();
9290 options.sort_by(|a, b| {
9291 a.distance_m
9292 .partial_cmp(&b.distance_m)
9293 .unwrap_or(std::cmp::Ordering::Equal)
9294 });
9295 options
9296 }
9297
9298 pub fn close_worker_give_target_picker(&mut self) {
9299 self.state.show_worker_give_target_picker = false;
9300 self.state.worker_give_target_picker = None;
9301 self.state.worker_give_target_picker_index = 0;
9302 }
9303
9304 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
9305 let Some(picker) = &self.state.worker_give_target_picker else {
9306 return;
9307 };
9308 let n = picker.options.len();
9309 if n == 0 {
9310 return;
9311 }
9312 let idx = self.state.worker_give_target_picker_index as i32;
9313 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9314 }
9315
9316 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
9317 let Some(picker) = self.state.worker_give_target_picker.clone() else {
9318 anyhow::bail!("give target picker not open");
9319 };
9320 let Some(opt) = picker
9321 .options
9322 .get(self.state.worker_give_target_picker_index)
9323 .cloned()
9324 else {
9325 anyhow::bail!("no worker selected");
9326 };
9327 let Some(worker) = self
9328 .state
9329 .hired_workers
9330 .iter()
9331 .find(|w| w.instance_id == opt.instance_id)
9332 .cloned()
9333 else {
9334 self.close_worker_give_target_picker();
9335 anyhow::bail!("worker no longer hired");
9336 };
9337 self.give_item_to_worker(
9338 &worker.instance_id,
9339 &worker.label,
9340 worker.x,
9341 worker.y,
9342 picker.item_instance_id,
9343 &picker.item_label,
9344 picker.quantity,
9345 )
9346 .await?;
9347 self.close_worker_give_target_picker();
9348 Ok(())
9349 }
9350
9351 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
9353 self.open_worker_give_target_picker()
9354 }
9355
9356 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
9358 let Some(worker) = self
9359 .state
9360 .hired_workers
9361 .get(self.state.workers_menu_index)
9362 .cloned()
9363 else {
9364 anyhow::bail!("select a hired worker first");
9365 };
9366 let (px, py, _) = self.state.player_position_with_z();
9367 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9368 if dist > WORKER_GIVE_RANGE_M {
9369 anyhow::bail!(
9370 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
9371 worker.label
9372 );
9373 }
9374 let options = self.state.giveable_inventory_options();
9375 if options.is_empty() {
9376 anyhow::bail!("nothing in inventory to give");
9377 }
9378 self.state.worker_give_picker = Some(WorkerGivePicker {
9379 worker_instance_id: worker.instance_id,
9380 worker_label: worker.label,
9381 options,
9382 });
9383 self.state.worker_give_picker_index = 0;
9384 self.state.show_worker_give_picker = true;
9385 Ok(())
9386 }
9387
9388 pub fn close_worker_give_picker(&mut self) {
9389 self.state.show_worker_give_picker = false;
9390 self.state.worker_give_picker = None;
9391 self.state.worker_give_picker_index = 0;
9392 }
9393
9394 pub fn worker_give_picker_move(&mut self, delta: i32) {
9395 let Some(picker) = &self.state.worker_give_picker else {
9396 return;
9397 };
9398 let n = picker.options.len();
9399 if n == 0 {
9400 return;
9401 }
9402 let idx = self.state.worker_give_picker_index as i32;
9403 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9404 }
9405
9406 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
9408 let Some(picker) = self.state.worker_give_picker.clone() else {
9409 anyhow::bail!("give picker not open");
9410 };
9411 let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
9412 anyhow::bail!("no item selected");
9413 };
9414 let Some(worker) = self
9415 .state
9416 .hired_workers
9417 .iter()
9418 .find(|w| w.instance_id == picker.worker_instance_id)
9419 .cloned()
9420 else {
9421 self.close_worker_give_picker();
9422 anyhow::bail!("worker no longer hired");
9423 };
9424 self.give_item_to_worker(
9425 &worker.instance_id,
9426 &worker.label,
9427 worker.x,
9428 worker.y,
9429 opt.item_instance_id,
9430 &opt.label,
9431 None,
9432 )
9433 .await?;
9434 let options = self.state.giveable_inventory_options();
9436 if options.is_empty() {
9437 self.close_worker_give_picker();
9438 } else {
9439 self.state.worker_give_picker = Some(WorkerGivePicker {
9440 worker_instance_id: picker.worker_instance_id,
9441 worker_label: picker.worker_label,
9442 options,
9443 });
9444 if self.state.worker_give_picker_index
9445 >= self
9446 .state
9447 .worker_give_picker
9448 .as_ref()
9449 .map(|p| p.options.len())
9450 .unwrap_or(0)
9451 {
9452 self.state.worker_give_picker_index = self
9453 .state
9454 .worker_give_picker
9455 .as_ref()
9456 .map(|p| p.options.len().saturating_sub(1))
9457 .unwrap_or(0);
9458 }
9459 }
9460 Ok(())
9461 }
9462
9463 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
9465 let Some(worker) = self
9466 .state
9467 .hired_workers
9468 .get(self.state.workers_menu_index)
9469 .cloned()
9470 else {
9471 anyhow::bail!("select a hired worker first");
9472 };
9473 let (px, py, _) = self.state.player_position_with_z();
9474 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9475 if dist > WORKER_GIVE_RANGE_M {
9476 anyhow::bail!(
9477 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
9478 worker.label
9479 );
9480 }
9481 let options = self.state.teachable_blueprint_options(&worker);
9482 if options.is_empty() {
9483 anyhow::bail!("no recipes you know that {} still needs", worker.label);
9484 }
9485 self.state.worker_teach_picker = Some(WorkerTeachPicker {
9486 worker_instance_id: worker.instance_id,
9487 worker_label: worker.label,
9488 worker_level: worker.level,
9489 options,
9490 });
9491 self.state.worker_teach_picker_index = 0;
9492 self.state.show_worker_teach_picker = true;
9493 Ok(())
9494 }
9495
9496 pub fn close_worker_teach_picker(&mut self) {
9497 self.state.show_worker_teach_picker = false;
9498 self.state.worker_teach_picker = None;
9499 self.state.worker_teach_picker_index = 0;
9500 }
9501
9502 pub fn worker_teach_picker_move(&mut self, delta: i32) {
9503 let Some(picker) = &self.state.worker_teach_picker else {
9504 return;
9505 };
9506 let n = picker.options.len();
9507 if n == 0 {
9508 return;
9509 }
9510 let idx = self.state.worker_teach_picker_index as i32;
9511 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9512 }
9513
9514 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
9515 let Some(picker) = self.state.worker_teach_picker.clone() else {
9516 anyhow::bail!("teach picker not open");
9517 };
9518 let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
9519 anyhow::bail!("nothing selected");
9520 };
9521 if !opt.level_ok {
9522 anyhow::bail!(
9523 "{} needs level {} (is level {})",
9524 picker.worker_label,
9525 opt.min_level,
9526 opt.worker_level
9527 );
9528 }
9529 if !opt.can_afford {
9530 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
9531 }
9532 let Some(worker) = self
9533 .state
9534 .hired_workers
9535 .iter()
9536 .find(|w| w.instance_id == picker.worker_instance_id)
9537 .cloned()
9538 else {
9539 anyhow::bail!("worker gone");
9540 };
9541 let (px, py, _) = self.state.player_position_with_z();
9542 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9543 if dist > WORKER_GIVE_RANGE_M {
9544 anyhow::bail!("worker {} too far — stand next to them", worker.label);
9545 }
9546 self.seq += 1;
9547 self.session
9548 .submit_intent(Intent::TeachWorkerBlueprint {
9549 entity_id: self.state.entity_id,
9550 worker_instance_id: picker.worker_instance_id.clone(),
9551 blueprint_id: opt.blueprint_id.clone(),
9552 seq: self.seq,
9553 })
9554 .await?;
9555 self.state.intents_sent += 1;
9556 self.state.push_log(format!(
9557 "Teaching {} to {} ({} cp)",
9558 opt.label, picker.worker_label, opt.cost_copper
9559 ));
9560 self.close_worker_teach_picker();
9561 Ok(())
9562 }
9563
9564 async fn give_item_to_worker(
9565 &mut self,
9566 worker_instance_id: &str,
9567 worker_label: &str,
9568 worker_x: f32,
9569 worker_y: f32,
9570 item_instance_id: uuid::Uuid,
9571 item_label: &str,
9572 quantity: Option<u32>,
9573 ) -> anyhow::Result<()> {
9574 let (px, py, _) = self.state.player_position_with_z();
9575 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
9576 if dist > WORKER_GIVE_RANGE_M {
9577 anyhow::bail!("worker {worker_label} too far — stand next to them");
9578 }
9579 self.seq += 1;
9580 self.session
9581 .submit_intent(Intent::GiveWorkerItem {
9582 entity_id: self.state.entity_id,
9583 worker_instance_id: worker_instance_id.to_string(),
9584 item_instance_id,
9585 quantity,
9586 seq: self.seq,
9587 })
9588 .await?;
9589 self.state.intents_sent += 1;
9590 self.state
9591 .push_log(format!("Gave {item_label} to {worker_label}"));
9592 Ok(())
9593 }
9594
9595 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
9597 let Some(worker) = self
9598 .state
9599 .hired_workers
9600 .get(self.state.workers_menu_index)
9601 .cloned()
9602 else {
9603 anyhow::bail!("select a hired worker first");
9604 };
9605 let (px, py, _) = self.state.player_position_with_z();
9606 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9607 if dist > WORKER_GIVE_RANGE_M {
9608 anyhow::bail!(
9609 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
9610 worker.label
9611 );
9612 }
9613 let options = Self::worker_inventory_options(&worker);
9614 if options.is_empty() {
9615 anyhow::bail!("{} isn't carrying anything", worker.label);
9616 }
9617 let initial_qty = options
9618 .first()
9619 .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
9620 .unwrap_or(1);
9621 self.state.worker_take_picker = Some(WorkerTakePicker {
9622 worker_instance_id: worker.instance_id,
9623 worker_label: worker.label,
9624 options,
9625 quantity: initial_qty,
9626 });
9627 self.state.worker_take_picker_index = 0;
9628 self.state.show_worker_take_picker = true;
9629 Ok(())
9630 }
9631
9632 fn worker_inventory_options(
9633 worker: &flatland_protocol::HiredWorkerView,
9634 ) -> Vec<WorkerGiveOption> {
9635 worker
9636 .inventory
9637 .iter()
9638 .filter_map(|stack| {
9639 let item_instance_id = stack.item_instance_id?;
9640 let label = stack
9641 .display_name
9642 .clone()
9643 .unwrap_or_else(|| stack.template_id.clone());
9644 let label = if stack.quantity > 1 {
9645 format!("{label} ×{}", stack.quantity)
9646 } else {
9647 label
9648 };
9649 Some(WorkerGiveOption {
9650 item_instance_id,
9651 label,
9652 quantity: stack.quantity,
9653 template_id: stack.template_id.clone(),
9654 })
9655 })
9656 .collect()
9657 }
9658
9659 pub fn close_worker_take_picker(&mut self) {
9660 self.state.show_worker_take_picker = false;
9661 self.state.worker_take_picker = None;
9662 self.state.worker_take_picker_index = 0;
9663 }
9664
9665 pub fn worker_take_picker_move(&mut self, delta: i32) {
9666 let Some(picker) = &self.state.worker_take_picker else {
9667 return;
9668 };
9669 let n = picker.options.len();
9670 if n == 0 {
9671 return;
9672 }
9673 let idx = self.state.worker_take_picker_index as i32;
9674 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9675 self.clamp_worker_take_quantity();
9676 }
9677
9678 pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
9679 let Some(picker) = &mut self.state.worker_take_picker else {
9680 return;
9681 };
9682 let max = picker
9683 .options
9684 .get(self.state.worker_take_picker_index)
9685 .map(|o| o.quantity.max(1))
9686 .unwrap_or(1);
9687 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
9688 picker.quantity = next as u32;
9689 }
9690
9691 pub fn worker_take_picker_set_quantity_max(&mut self) {
9692 let Some(picker) = &mut self.state.worker_take_picker else {
9693 return;
9694 };
9695 let max = picker
9696 .options
9697 .get(self.state.worker_take_picker_index)
9698 .map(|o| o.quantity.max(1))
9699 .unwrap_or(1);
9700 picker.quantity = max;
9701 }
9702
9703 fn clamp_worker_take_quantity(&mut self) {
9704 let Some(picker) = &mut self.state.worker_take_picker else {
9705 return;
9706 };
9707 let max = picker
9708 .options
9709 .get(self.state.worker_take_picker_index)
9710 .map(|o| o.quantity.max(1))
9711 .unwrap_or(1);
9712 if picker.quantity == 0 || picker.quantity > max {
9713 picker.quantity = if max > 1 { 1 } else { max };
9714 }
9715 }
9716
9717 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
9718 let Some(picker) = self.state.worker_take_picker.clone() else {
9719 anyhow::bail!("take picker not open");
9720 };
9721 let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
9722 anyhow::bail!("no item selected");
9723 };
9724 let Some(worker) = self
9725 .state
9726 .hired_workers
9727 .iter()
9728 .find(|w| w.instance_id == picker.worker_instance_id)
9729 .cloned()
9730 else {
9731 self.close_worker_take_picker();
9732 anyhow::bail!("worker no longer hired");
9733 };
9734 let qty = picker.quantity.clamp(1, opt.quantity.max(1));
9735 let intent_qty = if qty >= opt.quantity {
9736 None
9737 } else {
9738 Some(qty)
9739 };
9740 self.take_item_from_worker(
9741 &worker.instance_id,
9742 &worker.label,
9743 worker.x,
9744 worker.y,
9745 opt.item_instance_id,
9746 &opt.label,
9747 intent_qty,
9748 )
9749 .await?;
9750 Ok(())
9753 }
9754
9755 async fn take_item_from_worker(
9756 &mut self,
9757 worker_instance_id: &str,
9758 worker_label: &str,
9759 worker_x: f32,
9760 worker_y: f32,
9761 item_instance_id: uuid::Uuid,
9762 item_label: &str,
9763 quantity: Option<u32>,
9764 ) -> anyhow::Result<()> {
9765 let (px, py, _) = self.state.player_position_with_z();
9766 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
9767 if dist > WORKER_GIVE_RANGE_M {
9768 anyhow::bail!("worker {worker_label} too far — stand next to them");
9769 }
9770 self.seq += 1;
9771 self.session
9772 .submit_intent(Intent::TakeWorkerItem {
9773 entity_id: self.state.entity_id,
9774 worker_instance_id: worker_instance_id.to_string(),
9775 item_instance_id,
9776 quantity,
9777 seq: self.seq,
9778 })
9779 .await?;
9780 self.state.intents_sent += 1;
9781 let qty_note = quantity
9782 .map(|q| format!(" ×{q}"))
9783 .unwrap_or_default();
9784 self.state
9785 .push_log(format!("Taking {item_label}{qty_note} from {worker_label}…"));
9786 Ok(())
9787 }
9788
9789 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
9790 if !self.state.has_worker_lodging() {
9791 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
9792 }
9793 self.seq += 1;
9794 self.session
9795 .submit_intent(Intent::HireWorker {
9796 entity_id: self.state.entity_id,
9797 def_id: "worker_laborer".into(),
9798 wage_copper_per_interval: 8,
9799 lodging_container_id: None,
9800 job_yaml: None,
9801 seq: self.seq,
9802 })
9803 .await?;
9804 self.state.intents_sent += 1;
9805 Ok(())
9806 }
9807
9808 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
9809 let Some(worker) = self
9810 .state
9811 .hired_workers
9812 .get(self.state.workers_menu_index)
9813 .cloned()
9814 else {
9815 anyhow::bail!("select a hired worker first");
9816 };
9817 let lodging = worker.lodging_container_id.clone().or_else(|| {
9818 crate::worker_route_editor::owned_lodging_container_ids(
9819 &self.state.placed_containers,
9820 self.state.character_id,
9821 )
9822 .into_iter()
9823 .next()
9824 .map(|(id, _)| id)
9825 });
9826 let label = worker.label.clone();
9827 let editor = if let Some(route) = &worker.route {
9828 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
9829 worker.instance_id,
9830 worker.label,
9831 route,
9832 lodging,
9833 )
9834 } else {
9835 crate::worker_route_editor::WorkerRouteEditorState::new(
9836 worker.instance_id,
9837 worker.label,
9838 lodging,
9839 )
9840 };
9841 self.state.worker_route_editor = Some(editor);
9842 if let Some(ed) = self.state.worker_route_editor.as_mut() {
9843 if let Some(collapsed) =
9844 crate::client_config::ClientConfig::load().worker_route_panel_collapsed
9845 {
9846 ed.panel_collapsed = collapsed;
9847 }
9848 }
9849 self.state.show_workers_menu = false;
9850 self.state.push_log(format!(
9851 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
9852 ));
9853 Ok(())
9854 }
9855
9856 pub fn close_worker_route_editor(&mut self) {
9857 self.state.worker_route_editor = None;
9858 }
9859
9860 pub fn worker_route_editor_toggle_panel(&mut self) {
9861 if let Some(ed) = self.state.worker_route_editor.as_mut() {
9862 ed.toggle_panel_collapsed();
9863 let collapsed = ed.panel_collapsed;
9864 let mut cfg = crate::client_config::ClientConfig::load();
9865 let _ = cfg.save_worker_route_panel_collapsed(collapsed);
9866 }
9867 }
9868
9869 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
9870 let n = {
9871 let Some(ed) = self.state.worker_route_editor.as_mut() else {
9872 return;
9873 };
9874 ed.append_waypoint(x, y, z);
9875 ed.stop_count()
9876 };
9877 self.state
9878 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
9879 }
9880
9881 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
9884 let (px, py, _) = self.state.player_position_with_z();
9885 crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
9886 &self.state.placed_containers,
9887 &self.state.buildings,
9888 self.state.character_id,
9889 px,
9890 py,
9891 &self.state.hired_workers,
9892 )
9893 }
9894
9895 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
9896 self.state.route_editor_node_candidates()
9897 }
9898
9899 fn re_open_harvest_picker(
9900 &mut self,
9901 index: usize,
9902 picked: std::collections::BTreeSet<String>,
9903 ) {
9904 use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
9905 let nodes = self.state.route_editor_node_candidates();
9906 let index = if nodes.is_empty() {
9907 ROUTE_PICKER_DONE_ROW
9908 } else {
9909 index.max(1).min(nodes.len())
9910 };
9911 self.re_open_sheet(S::HarvestPicker {
9912 index,
9913 picked,
9914 nodes,
9915 });
9916 }
9917
9918 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
9919 let (px, py, _) = self.state.player_position_with_z();
9920 crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
9921 }
9922
9923 fn re_template_candidates(&self) -> Vec<String> {
9924 let mut extra = Vec::new();
9925 if let Some(ed) = self.state.worker_route_editor.as_ref() {
9926 for stop in &ed.stops {
9927 match stop {
9928 crate::worker_route_editor::WorkerRouteStop::DepositAt {
9929 filter: Some(filter),
9930 ..
9931 } => extra.extend(filter.iter().cloned()),
9932 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
9933 extra.push(template.clone());
9934 }
9935 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
9936 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
9937 extra.push(bp.output.clone());
9938 for input in &bp.inputs {
9939 extra.push(input.template_id.clone());
9940 }
9941 }
9942 }
9943 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
9944 for it in items {
9945 extra.push(it.template.clone());
9946 }
9947 }
9948 _ => {}
9949 }
9950 }
9951 if let Some(worker) = self
9953 .state
9954 .hired_workers
9955 .iter()
9956 .find(|w| w.instance_id == ed.worker_instance_id)
9957 {
9958 for recipe in &worker.known_blueprint_ids {
9959 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
9960 extra.push(bp.output.clone());
9961 }
9962 }
9963 }
9964 }
9965 crate::worker_route_editor::route_item_template_candidates(
9966 &self.state.placed_containers,
9967 self.state.character_id,
9968 &self.state.inventory,
9969 &self.state.blueprints,
9970 &self.state.resource_nodes,
9971 &extra,
9972 )
9973 }
9974
9975 fn re_blueprint_ids(&self) -> Vec<String> {
9976 let worker_known: Option<&[String]> = self
9977 .state
9978 .worker_route_editor
9979 .as_ref()
9980 .and_then(|ed| {
9981 self.state
9982 .hired_workers
9983 .iter()
9984 .find(|w| w.instance_id == ed.worker_instance_id)
9985 })
9986 .map(|w| w.known_blueprint_ids.as_slice());
9987 crate::worker_route_editor::worker_craft_blueprint_ids(
9988 &self.state.blueprints,
9989 worker_known,
9990 )
9991 }
9992
9993 fn re_bed_candidates(&self) -> Vec<(String, String)> {
9994 crate::worker_route_editor::owned_lodging_container_ids(
9995 &self.state.placed_containers,
9996 self.state.character_id,
9997 )
9998 }
9999
10000 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
10001 self.state
10002 .placed_containers
10003 .iter()
10004 .find(|c| c.id == container_id)
10005 .map(|c| c.contents.clone())
10006 .unwrap_or_default()
10007 }
10008
10009 fn re_sheet_supports_filter(&self) -> bool {
10012 use crate::worker_route_editor::RouteEditorSheet as S;
10013 self.state
10014 .worker_route_editor
10015 .as_ref()
10016 .is_some_and(|ed| {
10017 matches!(
10018 ed.sheet,
10019 S::HarvestPicker { .. }
10020 | S::SellItem { .. }
10021 | S::DepositFilter { .. }
10022 | S::WithdrawItems { .. }
10023 | S::WithdrawContainers { .. }
10024 | S::DepositContainers { .. }
10025 | S::SellNpcs { .. }
10026 | S::CraftBlueprint { .. }
10027 | S::BedPicker { .. }
10028 )
10029 })
10030 }
10031
10032 pub fn re_sheet_row_visible(&self, row: usize) -> bool {
10034 use crate::worker_route_editor::{
10035 harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
10036 ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
10037 };
10038 let Some(ed) = self.state.worker_route_editor.as_ref() else {
10039 return false;
10040 };
10041 let filter = &ed.sheet_filter;
10042 match &ed.sheet {
10043 S::HarvestPicker { nodes, .. } => {
10044 harvest_picker_row_matches(nodes, row, filter)
10045 }
10046 S::SellItem { templates, .. } => {
10047 if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
10048 return true;
10049 }
10050 let slot = row.saturating_sub(2);
10051 templates.get(slot).is_some_and(|t| {
10052 let label = self.state.template_display_name(t);
10053 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10054 })
10055 }
10056 S::DepositFilter { rows, .. } => {
10057 if row >= rows.len() {
10058 return true;
10059 }
10060 rows.get(row).is_some_and(|(t, _)| {
10061 let label = self.state.template_display_name(t);
10062 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10063 })
10064 }
10065 S::WithdrawItems { lines, .. } => {
10066 if row >= lines.len() {
10067 return true;
10068 }
10069 lines.get(row).is_some_and(|l| {
10070 let label = self.state.template_display_name(&l.template);
10071 list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
10072 })
10073 }
10074 S::WithdrawContainers { .. } | S::DepositContainers { .. } => self
10075 .re_container_candidates()
10076 .get(row)
10077 .is_some_and(|c| {
10078 list_filter_row_matches(
10079 filter,
10080 Some(c.dist),
10081 &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
10082 )
10083 }),
10084 S::SellNpcs { .. } => {
10085 if row == 0 {
10086 return true;
10087 }
10088 self.re_npc_candidates().get(row - 1).is_some_and(|n| {
10089 list_filter_row_matches(filter, Some(n.dist), &[n.label.as_str(), n.id.as_str()])
10090 })
10091 }
10092 S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
10093 let label = self
10094 .state
10095 .blueprints
10096 .iter()
10097 .find(|b| &b.id == id)
10098 .map(|b| {
10099 if b.label.is_empty() {
10100 id.as_str()
10101 } else {
10102 b.label.as_str()
10103 }
10104 })
10105 .unwrap_or(id.as_str());
10106 list_filter_row_matches(filter, None, &[id.as_str(), label])
10107 }),
10108 S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
10109 list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
10110 }),
10111 _ => true,
10112 }
10113 }
10114
10115 fn re_sheet_clamp_index(&mut self) {
10116 let count = self.re_sheet_row_count();
10117 if count == 0 {
10118 return;
10119 }
10120 let cur = self.re_sheet_index();
10121 if self.re_sheet_row_visible(cur) {
10122 return;
10123 }
10124 for offset in 1..count {
10125 if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
10126 self.re_sheet_set_index(cur + offset);
10127 return;
10128 }
10129 if cur >= offset && self.re_sheet_row_visible(cur - offset) {
10130 self.re_sheet_set_index(cur - offset);
10131 return;
10132 }
10133 }
10134 }
10135
10136 fn re_sheet_set_index(&mut self, index: usize) {
10137 use crate::worker_route_editor::RouteEditorSheet as S;
10138 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10139 return;
10140 };
10141 match &mut ed.sheet {
10142 S::AddMenu { index: slot }
10143 | S::WaypointMenu { index: slot }
10144 | S::HarvestPicker { index: slot, .. }
10145 | S::WithdrawContainers { index: slot }
10146 | S::DepositContainers { index: slot }
10147 | S::SellNpcs { index: slot }
10148 | S::CraftBlueprint { index: slot }
10149 | S::BedPicker { index: slot }
10150 | S::FarmPlotPicker { index: slot, .. }
10151 | S::FarmPlantSeed { index: slot, .. }
10152 | S::WithdrawItems { index: slot, .. }
10153 | S::DepositFilter { index: slot, .. }
10154 | S::SellItem { index: slot, .. } => *slot = index,
10155 _ => {}
10156 }
10157 }
10158
10159 pub fn re_focus_sheet_filter(&mut self) {
10160 if !self.re_sheet_supports_filter() {
10161 return;
10162 }
10163 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10164 ed.sheet_filter_focused = true;
10165 }
10166 }
10167
10168 pub fn re_blur_sheet_filter_keep_text(&mut self) {
10169 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10170 return;
10171 };
10172 if !ed.sheet_filter_focused {
10173 return;
10174 }
10175 ed.sheet_filter_focused = false;
10176 self.re_sheet_clamp_index();
10177 }
10178
10179 pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
10180 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10181 return false;
10182 };
10183 if ed.sheet_filter_focused {
10184 ed.sheet_filter_focused = false;
10185 self.re_sheet_clamp_index();
10186 return true;
10187 }
10188 if !ed.sheet_filter.is_empty() {
10189 ed.sheet_filter.clear();
10190 self.re_sheet_clamp_index();
10191 return true;
10192 }
10193 false
10194 }
10195
10196 pub fn re_append_sheet_filter_char(&mut self, ch: char) {
10197 if ch.is_control() {
10198 return;
10199 }
10200 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10201 return;
10202 };
10203 if !ed.sheet_filter_focused {
10204 return;
10205 }
10206 ed.sheet_filter.push(ch);
10207 self.re_sheet_set_index(0);
10208 self.re_sheet_clamp_index();
10209 }
10210
10211 pub fn re_sheet_filter_backspace(&mut self) {
10212 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10213 return;
10214 };
10215 if !ed.sheet_filter_focused {
10216 return;
10217 }
10218 ed.sheet_filter.pop();
10219 self.re_sheet_set_index(0);
10220 self.re_sheet_clamp_index();
10221 }
10222
10223 pub fn re_sheet_row_count(&self) -> usize {
10225 use crate::worker_route_editor::{
10226 harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
10227 };
10228 let Some(ed) = self.state.worker_route_editor.as_ref() else {
10229 return 0;
10230 };
10231 match &ed.sheet {
10232 S::Stops => ed.stops.len(),
10233 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
10234 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
10235 S::WaypointMapPick => 0,
10236 S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
10237 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
10238 self.re_container_candidates().len()
10239 }
10240 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()),
10244 S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
10245 S::WaitEntry { .. } => 1,
10246 S::BedPicker { .. } => self.re_bed_candidates().len(),
10247 S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
10248 S::FarmPlantSeed { seeds, .. } => seeds.len(),
10249 }
10250 }
10251
10252 pub fn re_sheet_index(&self) -> usize {
10254 use crate::worker_route_editor::RouteEditorSheet as S;
10255 let Some(ed) = self.state.worker_route_editor.as_ref() else {
10256 return 0;
10257 };
10258 match &ed.sheet {
10259 S::AddMenu { index }
10260 | S::WaypointMenu { index }
10261 | S::HarvestPicker { index, .. }
10262 | S::WithdrawContainers { index }
10263 | S::DepositContainers { index }
10264 | S::SellNpcs { index }
10265 | S::CraftBlueprint { index }
10266 | S::BedPicker { index }
10267 | S::FarmPlotPicker { index, .. }
10268 | S::FarmPlantSeed { index, .. }
10269 | S::WithdrawItems { index, .. }
10270 | S::DepositFilter { index, .. }
10271 | S::SellItem { index, .. } => *index,
10272 _ => 0,
10273 }
10274 }
10275
10276 pub fn re_sheet_move(&mut self, delta: i32) {
10278 let count = self.re_sheet_row_count();
10279 if count == 0 {
10280 return;
10281 }
10282 let cur = self.re_sheet_index();
10283 let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
10284 self.re_sheet_set_index(next);
10285 }
10286
10287 pub fn re_sheet_page(&mut self, pages: i32) {
10288 let count = self.re_sheet_row_count();
10289 if count == 0 {
10290 return;
10291 }
10292 let cur = self.re_sheet_index();
10293 let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
10294 self.re_sheet_set_index(next);
10295 }
10296
10297 pub fn re_sheet_adjust(&mut self, delta: i32) {
10299 use crate::worker_route_editor::RouteEditorSheet as S;
10300 let index = self.re_sheet_index();
10301 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10302 return;
10303 };
10304 match &mut ed.sheet {
10305 S::WithdrawItems { lines, .. } => {
10306 if let Some(line) = lines.get_mut(index) {
10307 line.adjust_qty(delta);
10308 }
10309 }
10310 S::WaitEntry { ticks } => {
10311 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
10312 }
10313 _ => {}
10314 }
10315 }
10316
10317 pub fn re_sheet_back(&mut self) {
10318 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10319 return;
10320 };
10321 use crate::worker_route_editor::RouteEditorSheet as S;
10322 let was_editing = ed.editing_index.is_some();
10323 let from_top_picker = matches!(
10324 ed.sheet,
10325 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
10326 );
10327 ed.sheet_back();
10328 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
10329 self.state
10331 .push_log("Route: left edit sheet — press s to save current stops".to_string());
10332 }
10333 }
10334
10335 pub fn re_at_root_sheet(&self) -> bool {
10337 self.state
10338 .worker_route_editor
10339 .as_ref()
10340 .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
10341 }
10342
10343 pub fn re_open_add_menu(&mut self) {
10344 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10345 ed.open_add_menu();
10346 }
10347 }
10348
10349 pub fn re_open_bed_picker(&mut self) {
10350 let beds = self.re_bed_candidates();
10351 if beds.is_empty() {
10352 self.state
10353 .push_log("Route: place a camp bed first".to_string());
10354 return;
10355 }
10356 let current = self
10357 .state
10358 .worker_route_editor
10359 .as_ref()
10360 .and_then(|ed| ed.lodging_container_id.clone());
10361 let index = current
10362 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
10363 .unwrap_or(0);
10364 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
10365 }
10366
10367 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
10368 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10369 ed.open_sheet(sheet);
10370 }
10371 }
10372
10373 fn re_confirm_stop(
10375 &mut self,
10376 stop: crate::worker_route_editor::WorkerRouteStop,
10377 what: String,
10378 ) {
10379 let appended = self
10380 .state
10381 .worker_route_editor
10382 .as_mut()
10383 .is_some_and(|ed| ed.confirm_stop(stop));
10384 if appended {
10385 self.state.push_log(format!("Route: + {what}"));
10386 } else {
10387 self.state
10388 .push_log(format!("Route: {what} already in route — selected it"));
10389 }
10390 }
10391
10392 fn re_open_withdraw_items(&mut self, container_id: String) {
10393 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
10394 let contents = self.re_container_contents(&container_id);
10395 let existing = self
10399 .state
10400 .worker_route_editor
10401 .as_ref()
10402 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10403 .and_then(|stop| match stop {
10404 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
10405 _ => None,
10406 })
10407 .unwrap_or_default();
10408 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
10409 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10412 let _ = ed.retarget_withdraw_container(container_id.clone());
10413 }
10414 self.re_open_sheet(S::WithdrawItems {
10415 container_id,
10416 lines,
10417 index: 0,
10418 });
10419 }
10420
10421 fn re_withdraw_items_activate(&mut self, index: usize) {
10422 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
10423 enum Outcome {
10424 Cycled,
10425 Confirmed(String),
10426 Empty,
10427 }
10428 let outcome = {
10429 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10430 return;
10431 };
10432 let S::WithdrawItems {
10433 container_id,
10434 lines,
10435 index: sheet_index,
10436 } = &mut ed.sheet
10437 else {
10438 return;
10439 };
10440 *sheet_index = index;
10441 if index < lines.len() {
10442 lines[index].cycle();
10443 Outcome::Cycled
10444 } else {
10445 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
10446 if items.is_empty() {
10447 Outcome::Empty
10448 } else {
10449 let stop = WorkerRouteStop::WithdrawFrom {
10450 container_id: container_id.clone(),
10451 items,
10452 };
10453 let summary = stop.summary();
10454 ed.confirm_stop(stop);
10455 Outcome::Confirmed(summary)
10456 }
10457 }
10458 };
10459 match outcome {
10460 Outcome::Cycled => {}
10461 Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
10462 Outcome::Empty => self
10463 .state
10464 .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
10465 }
10466 }
10467
10468 fn re_open_deposit_filter(&mut self, container_id: String) {
10469 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10470 let existing_filter = self
10472 .state
10473 .worker_route_editor
10474 .as_ref()
10475 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10476 .and_then(|stop| match stop {
10477 WorkerRouteStop::DepositAt { filter, .. } => {
10478 Some(filter.clone().unwrap_or_default())
10479 }
10480 _ => None,
10481 });
10482 let mut candidates = self.re_template_candidates();
10483 if let Some(ref chosen) = existing_filter {
10484 for t in chosen {
10485 if !candidates.iter().any(|c| c == t) {
10486 candidates.push(t.clone());
10487 }
10488 }
10489 candidates.sort();
10490 candidates.dedup();
10491 }
10492 let rows: Vec<(String, bool)> = match existing_filter {
10493 Some(chosen) => candidates
10494 .iter()
10495 .map(|t| (t.clone(), chosen.contains(t)))
10496 .collect(),
10497 None => candidates.into_iter().map(|t| (t, false)).collect(),
10498 };
10499 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10500 let _ = ed.retarget_deposit_container(container_id.clone());
10501 }
10502 self.re_open_sheet(S::DepositFilter {
10503 container_id,
10504 rows,
10505 index: 0,
10506 });
10507 }
10508
10509 fn re_deposit_filter_activate(&mut self, index: usize) {
10510 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10511 let mut confirmed: Option<String> = None;
10512 {
10513 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10514 return;
10515 };
10516 let S::DepositFilter {
10517 container_id,
10518 rows,
10519 index: sheet_index,
10520 } = &mut ed.sheet
10521 else {
10522 return;
10523 };
10524 *sheet_index = index;
10525 if index < rows.len() {
10526 rows[index].1 = !rows[index].1;
10527 } else {
10528 let chosen: Vec<String> = rows
10530 .iter()
10531 .filter(|(_, on)| *on)
10532 .map(|(t, _)| t.clone())
10533 .collect();
10534 let filter = if chosen.is_empty() { None } else { Some(chosen) };
10535 let stop = WorkerRouteStop::DepositAt {
10536 container_id: container_id.clone(),
10537 filter,
10538 };
10539 confirmed = Some(stop.summary());
10540 ed.confirm_stop(stop);
10541 }
10542 }
10543 if let Some(what) = confirmed {
10544 self.state.push_log(format!("Route: + {what}"));
10545 }
10546 }
10547
10548 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
10549 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10550 let templates = self.re_template_candidates();
10551 if templates.is_empty() {
10552 self.state.push_log(
10553 "Route: no item templates available — learn a craft recipe or place a harvest node first"
10554 .to_string(),
10555 );
10556 return;
10557 }
10558 let (pre_npc, pre_template, pre_all) = self
10560 .state
10561 .worker_route_editor
10562 .as_ref()
10563 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10564 .and_then(|stop| match stop {
10565 WorkerRouteStop::TradeWith {
10566 npc_id,
10567 template,
10568 sell_all,
10569 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
10570 _ => None,
10571 })
10572 .unwrap_or((None, None, true));
10573 let npc_id = npc_id.or(pre_npc);
10574 let mut picked = std::collections::BTreeSet::new();
10575 if let Some(t) = pre_template {
10576 picked.insert(t);
10577 }
10578 self.re_open_sheet(S::SellItem {
10579 npc_id,
10580 templates,
10581 index: if picked.is_empty() {
10582 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
10583 } else {
10584 2
10585 },
10586 sell_all: pre_all,
10587 picked,
10588 });
10589 }
10590
10591 fn re_sell_item_activate(&mut self, index: usize) {
10592 use crate::worker_route_editor::{
10593 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
10594 };
10595 let mut batch_log: Option<String> = None;
10596 {
10597 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10598 return;
10599 };
10600 let S::SellItem {
10601 npc_id,
10602 templates,
10603 index: sheet_index,
10604 sell_all,
10605 picked,
10606 } = &mut ed.sheet
10607 else {
10608 return;
10609 };
10610 *sheet_index = index;
10611 if index == ROUTE_PICKER_DONE_ROW {
10612 if picked.is_empty() {
10613 batch_log = Some(
10614 "Route: pick at least one item (Space toggles, Done confirms)".into(),
10615 );
10616 } else {
10617 let picks: Vec<String> = picked.iter().cloned().collect();
10618 let npc = npc_id.clone();
10619 let all = *sell_all;
10620 let added = ed.confirm_trade_picks(npc, &picks, all);
10621 batch_log = Some(format!("Route: + {added} sell stop(s)"));
10622 }
10623 } else if index == SELL_ITEM_TOGGLE_ROW {
10624 *sell_all = !*sell_all;
10625 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
10626 if picked.contains(template) {
10627 picked.remove(template);
10628 } else {
10629 picked.insert(template.clone());
10630 }
10631 }
10632 }
10633 if let Some(msg) = batch_log {
10634 self.state.push_log(msg);
10635 }
10636 }
10637
10638 pub fn re_edit_selected_stop(&mut self) {
10640 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10641 let Some(stop) = self
10642 .state
10643 .worker_route_editor
10644 .as_ref()
10645 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
10646 else {
10647 self.state
10648 .push_log("Route: no stop selected — press a to add one".to_string());
10649 return;
10650 };
10651 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10652 ed.begin_edit_selected();
10653 }
10654 match stop {
10655 WorkerRouteStop::Waypoint { .. } => {
10656 self.re_open_sheet(S::WaypointMenu { index: 0 });
10657 }
10658 WorkerRouteStop::HarvestNode { node_id } => {
10659 let nodes = self.state.route_editor_node_candidates();
10660 if nodes.is_empty() {
10661 self.re_cancel_edit();
10662 self.state
10663 .push_log("Route: no harvestable nodes visible to retarget".to_string());
10664 } else {
10665 let mut picked = std::collections::BTreeSet::new();
10666 picked.insert(node_id.clone());
10667 let index = nodes
10668 .iter()
10669 .position(|n| n.id == node_id)
10670 .map(|i| i + 1)
10671 .unwrap_or(1);
10672 self.re_open_harvest_picker(index, picked);
10673 }
10674 }
10675 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
10676 let containers = self.re_container_candidates();
10679 if containers.is_empty() {
10680 self.re_cancel_edit();
10681 self.state
10682 .push_log("Route: place a storage chest first".to_string());
10683 } else {
10684 let index = containers
10685 .iter()
10686 .position(|c| c.id == container_id)
10687 .unwrap_or(0);
10688 self.re_open_sheet(S::WithdrawContainers { index });
10689 }
10690 }
10691 WorkerRouteStop::DepositAt { container_id, .. } => {
10692 let containers = self.re_container_candidates();
10693 if containers.is_empty() {
10694 self.re_cancel_edit();
10695 self.state
10696 .push_log("Route: place a storage chest first".to_string());
10697 } else {
10698 let index = containers
10699 .iter()
10700 .position(|c| c.id == container_id)
10701 .unwrap_or(0);
10702 self.re_open_sheet(S::DepositContainers { index });
10703 }
10704 }
10705 WorkerRouteStop::TradeWith { npc_id, .. } => {
10706 let npcs = self.re_npc_candidates();
10707 let index = npc_id
10709 .as_ref()
10710 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
10711 .unwrap_or(0);
10712 self.re_open_sheet(S::SellNpcs { index });
10713 }
10714 WorkerRouteStop::CraftAt { blueprint, .. } => {
10715 let bps = self.re_blueprint_ids();
10716 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
10717 if bps.is_empty() {
10718 self.re_cancel_edit();
10719 self.state
10720 .push_log("Route: no known blueprints to retarget".to_string());
10721 } else {
10722 self.re_open_sheet(S::CraftBlueprint { index });
10723 }
10724 }
10725 WorkerRouteStop::CultivatePlot { .. } => {
10726 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate);
10727 }
10728 WorkerRouteStop::PlantPlot { .. } => {
10729 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
10730 }
10731 WorkerRouteStop::HarvestPlot { .. } => {
10732 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
10733 }
10734 WorkerRouteStop::RestIfNeeded => {
10735 self.re_cancel_edit();
10736 self.state
10737 .push_log("Route: rest has no settings (change the bed with l)".to_string());
10738 }
10739 WorkerRouteStop::Wait { wait_ticks } => {
10740 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
10741 }
10742 }
10743 }
10744
10745 fn re_cancel_edit(&mut self) {
10746 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10747 ed.editing_index = None;
10748 }
10749 }
10750
10751 pub fn worker_route_editor_ui_click(
10754 &mut self,
10755 click: crate::worker_route_editor::RouteEditorClick,
10756 ) {
10757 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
10758 match click {
10759 RouteEditorClick::SelectStop(i) => {
10760 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10761 ed.sheet = S::Stops;
10762 ed.select_stop(i);
10763 }
10764 }
10765 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
10766 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
10767 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
10768 }
10769 }
10770
10771 pub fn re_sheet_row_activate(&mut self, row: usize) {
10773 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
10774 let Some(sheet) = self
10775 .state
10776 .worker_route_editor
10777 .as_ref()
10778 .map(|ed| ed.sheet.clone())
10779 else {
10780 return;
10781 };
10782 match sheet {
10783 S::Stops => {
10784 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10785 ed.select_stop(row);
10786 }
10787 }
10788 S::AddMenu { .. } => match row {
10789 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
10790 1 => {
10791 if self.re_node_candidates().is_empty() {
10792 self.state
10793 .push_log("Route: no harvestable nodes visible in this region".to_string());
10794 } else {
10795 self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
10796 }
10797 }
10798 2 | 3 => {
10799 if self.re_container_candidates().is_empty() {
10800 self.state
10801 .push_log("Route: place a storage chest first".to_string());
10802 } else if row == 2 {
10803 self.re_open_sheet(S::WithdrawContainers { index: 0 });
10804 } else {
10805 self.re_open_sheet(S::DepositContainers { index: 0 });
10806 }
10807 }
10808 4 => {
10809 if self.re_template_candidates().is_empty() {
10810 self.state.push_log(
10811 "Route: no item templates available — learn a craft recipe or place a harvest node first"
10812 .to_string(),
10813 );
10814 } else {
10815 self.re_open_sheet(S::SellNpcs { index: 0 });
10816 }
10817 }
10818 5 => {
10819 if self.re_blueprint_ids().is_empty() {
10820 self.state.push_log(
10821 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
10822 .to_string(),
10823 );
10824 } else {
10825 self.re_open_sheet(S::CraftBlueprint { index: 0 });
10826 }
10827 }
10828 6 => self.re_confirm_stop(
10829 WorkerRouteStop::RestIfNeeded,
10830 "rest at lodging (if needed)".into(),
10831 ),
10832 7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
10833 8 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate),
10834 9 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant),
10835 10 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
10836 _ => {}
10837 },
10838 S::WaypointMenu { .. } => match row {
10839 0 => {
10840 let (x, y, z) = self.state.player_position_with_z();
10841 let stop = WorkerRouteStop::Waypoint { x, y, z };
10842 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
10843 }
10844 1 => {
10845 self.re_open_sheet(S::WaypointMapPick);
10846 self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
10847 }
10848 _ => {}
10849 },
10850 S::HarvestPicker { .. } => {
10851 let mut log: Option<String> = None;
10852 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10853 let S::HarvestPicker {
10854 index: sheet_index,
10855 picked,
10856 nodes,
10857 } = &mut ed.sheet
10858 else {
10859 return;
10860 };
10861 *sheet_index = row;
10862 if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
10863 if picked.is_empty() {
10864 log = Some(
10865 "Route: pick at least one node (Space toggles, Done confirms)"
10866 .into(),
10867 );
10868 } else {
10869 let ids: Vec<String> = picked.iter().cloned().collect();
10870 let added = ed.confirm_harvest_picks(&ids);
10871 log = Some(format!("Route: + {added} harvest stop(s)"));
10872 }
10873 } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
10874 if picked.contains(&n.id) {
10875 picked.remove(&n.id);
10876 } else {
10877 picked.insert(n.id.clone());
10878 }
10879 }
10880 }
10881 if let Some(msg) = log {
10882 self.state.push_log(msg);
10883 }
10884 }
10885 S::WithdrawContainers { .. } => {
10886 let containers = self.re_container_candidates();
10887 if let Some(c) = containers.get(row) {
10888 let id = c.id.clone();
10889 self.re_open_withdraw_items(id);
10890 }
10891 }
10892 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
10893 S::DepositContainers { .. } => {
10894 let containers = self.re_container_candidates();
10895 if let Some(c) = containers.get(row) {
10896 let id = c.id.clone();
10897 self.re_open_deposit_filter(id);
10898 }
10899 }
10900 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
10901 S::SellNpcs { .. } => {
10902 let npcs = self.re_npc_candidates();
10903 let npc_id = if row == 0 {
10904 None
10905 } else {
10906 npcs.get(row - 1).map(|n| n.id.clone())
10907 };
10908 if row == 0 || npc_id.is_some() {
10909 self.re_open_sell_item(npc_id);
10910 }
10911 }
10912 S::SellItem { .. } => self.re_sell_item_activate(row),
10913 S::CraftBlueprint { .. } => {
10914 let bps = self.re_blueprint_ids();
10915 if let Some(bp) = bps.get(row) {
10916 let stop = WorkerRouteStop::CraftAt {
10917 device: "hand".into(),
10918 blueprint: bp.clone(),
10919 qty: None,
10920 };
10921 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
10922 }
10923 }
10924 S::WaitEntry { ticks } => {
10925 let stop = WorkerRouteStop::Wait {
10926 wait_ticks: ticks,
10927 };
10928 self.re_confirm_stop(stop, format!("wait {ticks}t"));
10929 }
10930 S::BedPicker { .. } => {
10931 let beds = self.re_bed_candidates();
10932 if let Some((id, name)) = beds.get(row) {
10933 let (id, name) = (id.clone(), name.clone());
10934 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10935 ed.lodging_container_id = Some(id.clone());
10936 ed.sheet = S::Stops;
10937 }
10938 self.state
10939 .push_log(format!("Route: rest bed set to {name}"));
10940 }
10941 }
10942 S::FarmPlotPicker { action, .. } => {
10943 let plots = self.re_farm_plot_candidates();
10944 let Some(plot) = plots.get(row).cloned() else {
10945 return;
10946 };
10947 match action {
10948 crate::worker_route_editor::FarmPlotAction::Cultivate => {
10949 let label = plot_route_label(&plot);
10950 self.re_confirm_stop(
10951 WorkerRouteStop::CultivatePlot {
10952 plot_id: plot.plot_id,
10953 },
10954 format!("cultivate {label}"),
10955 );
10956 }
10957 crate::worker_route_editor::FarmPlotAction::Harvest => {
10958 let label = plot_route_label(&plot);
10959 self.re_confirm_stop(
10960 WorkerRouteStop::HarvestPlot {
10961 plot_id: plot.plot_id,
10962 },
10963 format!("harvest {label}"),
10964 );
10965 }
10966 crate::worker_route_editor::FarmPlotAction::Plant => {
10967 let seeds = self.re_farm_seed_candidates();
10968 if seeds.is_empty() {
10969 self.state.push_log(
10970 "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
10971 );
10972 return;
10973 }
10974 self.re_open_sheet(S::FarmPlantSeed {
10975 plot_id: plot.plot_id,
10976 seeds,
10977 index: 0,
10978 });
10979 }
10980 }
10981 }
10982 S::FarmPlantSeed { plot_id, seeds, .. } => {
10983 if let Some(seed) = seeds.get(row).cloned() {
10984 self.re_confirm_stop(
10985 WorkerRouteStop::PlantPlot {
10986 plot_id,
10987 seed_template: seed.clone(),
10988 },
10989 format!("plant {seed}"),
10990 );
10991 }
10992 }
10993 S::WaypointMapPick => {}
10994 }
10995 }
10996
10997 fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
10998 use crate::worker_route_editor::RouteEditorSheet as S;
10999 if self.re_farm_plot_candidates().is_empty() {
11000 self.state.push_log(
11001 "Route: no farmable plots visible — claim land or get farm access first",
11002 );
11003 return;
11004 }
11005 self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
11006 }
11007
11008 fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
11009 self.state
11010 .property_plots
11011 .iter()
11012 .filter(|p| p.is_mine || p.may_farm)
11013 .cloned()
11014 .collect()
11015 }
11016
11017 fn re_farm_seed_candidates(&self) -> Vec<String> {
11021 let mut set = std::collections::BTreeSet::new();
11022 let looks_like_seed = |id: &str| {
11023 id.ends_with("_seed") || id == "potato_seed" || id == "carrot_seed"
11024 };
11025 for (id, _, _) in self.state.farm_seed_entries() {
11026 set.insert(id);
11027 }
11028 for c in &self.state.placed_containers {
11029 let mine = match (self.state.character_id, c.owner_character_id) {
11030 (Some(a), Some(b)) => a == b,
11031 _ => false,
11032 };
11033 if !mine {
11034 continue;
11035 }
11036 for s in &c.contents {
11037 if s.quantity > 0
11038 && (s.props.contains_key("seed_for") || looks_like_seed(&s.template_id))
11039 {
11040 set.insert(s.template_id.clone());
11041 }
11042 }
11043 }
11044 if let Some(ed) = self.state.worker_route_editor.as_ref() {
11045 for stop in &ed.stops {
11046 if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } = stop
11047 {
11048 for it in items {
11049 if looks_like_seed(&it.template) {
11050 set.insert(it.template.clone());
11051 }
11052 }
11053 }
11054 if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
11055 seed_template, ..
11056 } = stop
11057 {
11058 if !seed_template.is_empty() {
11059 set.insert(seed_template.clone());
11060 }
11061 }
11062 }
11063 }
11064 for id in self.state.inventory_hints.keys() {
11065 if looks_like_seed(id) {
11066 set.insert(id.clone());
11067 }
11068 }
11069 for id in ["potato_seed", "carrot_seed"] {
11071 set.insert(id.to_string());
11072 }
11073 set.into_iter().collect()
11074 }
11075
11076 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
11083 use crate::worker_route_editor as wre;
11084 use wre::RouteEditorSheet as S;
11085 if self.state.worker_route_editor.is_none() {
11086 return;
11087 }
11088 let sheet = self
11089 .state
11090 .worker_route_editor
11091 .as_ref()
11092 .map(|ed| ed.sheet.clone())
11093 .unwrap_or(S::Stops);
11094 match sheet {
11095 S::WaypointMapPick => {
11096 let (_, _, z) = self.state.player_position_with_z();
11097 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
11098 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
11099 let editing = self
11101 .state
11102 .worker_route_editor
11103 .as_ref()
11104 .is_some_and(|ed| ed.editing_index.is_some());
11105 if !editing {
11106 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11107 ed.sheet = S::WaypointMapPick;
11108 }
11109 }
11110 }
11111 S::HarvestPicker { .. } => {
11112 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11113 let mut log: Option<String> = None;
11114 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11115 let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
11116 return;
11117 };
11118 let selected = if picked.contains(&node.id) {
11119 picked.remove(&node.id);
11120 false
11121 } else {
11122 picked.insert(node.id.clone());
11123 true
11124 };
11125 log = Some(format!(
11126 "Route: {} {}",
11127 if selected { "selected" } else { "deselected" },
11128 node.label
11129 ));
11130 }
11131 if let Some(msg) = log {
11132 self.state.push_log(msg);
11133 }
11134 }
11135 }
11136 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
11137 if let Some(cid) = wre::pick_storage_container_at(
11139 &self.state.placed_containers,
11140 self.state.character_id,
11141 x,
11142 y,
11143 ) {
11144 self.re_open_withdraw_items(cid);
11145 }
11146 }
11147 S::DepositContainers { .. } | S::DepositFilter { .. } => {
11148 if let Some(cid) = wre::pick_storage_container_at(
11149 &self.state.placed_containers,
11150 self.state.character_id,
11151 x,
11152 y,
11153 ) {
11154 self.re_open_deposit_filter(cid);
11155 }
11156 }
11157 S::SellNpcs { .. } => {
11158 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11159 self.re_open_sell_item(Some(npc_id));
11160 }
11161 }
11162 S::SellItem { .. } => {
11163 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11164 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11165 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
11166 *slot = Some(npc_id.clone());
11167 }
11168 }
11169 self.state
11170 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11171 }
11172 }
11173 _ => self.worker_route_editor_quick_add_click(x, y),
11175 }
11176 }
11177
11178 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
11182 use crate::worker_route_editor as wre;
11183 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
11184 let dx = ax - bx;
11185 let dy = ay - by;
11186 (dx * dx + dy * dy).sqrt()
11187 };
11188
11189 let selected_stop_kind = self
11192 .state
11193 .worker_route_editor
11194 .as_ref()
11195 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
11196 .map(|s| match s {
11197 wre::WorkerRouteStop::TradeWith { .. } => 1,
11198 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
11199 _ => 0,
11200 })
11201 .unwrap_or(0);
11202 if selected_stop_kind == 1 {
11203 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11204 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11205 ed.set_selected_trade_npc(npc_id.clone());
11206 }
11207 self.state
11208 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11209 return;
11210 }
11211 }
11212 if selected_stop_kind == 2 {
11213 if let Some(cid) = wre::pick_storage_container_at(
11214 &self.state.placed_containers,
11215 self.state.character_id,
11216 x,
11217 y,
11218 ) {
11219 let name = self
11220 .state
11221 .placed_containers
11222 .iter()
11223 .find(|c| c.id == cid)
11224 .map(|c| c.display_name.clone())
11225 .unwrap_or_else(|| "container".into());
11226 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11227 ed.set_selected_withdraw_container(cid.clone());
11228 }
11229 self.state
11230 .push_log(format!("Route: withdraw source → {name}"));
11231 return;
11232 }
11233 }
11234
11235 enum Target {
11238 Bed(String),
11239 Container(String),
11240 Npc(String, String),
11241 Node(String, String),
11242 }
11243 let mut best: Option<(f32, u8, Target)> = None;
11244 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
11245 let better = match best {
11246 None => true,
11247 Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
11248 };
11249 if better {
11250 *best = Some((d, rank, t));
11251 }
11252 };
11253 if let Some(bed_id) = wre::pick_lodging_container_at(
11254 &self.state.placed_containers,
11255 self.state.character_id,
11256 x,
11257 y,
11258 ) {
11259 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
11260 let already_bed = self
11263 .state
11264 .worker_route_editor
11265 .as_ref()
11266 .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
11267 if already_bed {
11268 consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
11269 } else {
11270 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
11271 }
11272 }
11273 }
11274 if let Some(cid) = wre::pick_storage_container_at(
11275 &self.state.placed_containers,
11276 self.state.character_id,
11277 x,
11278 y,
11279 ) {
11280 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
11281 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
11282 }
11283 }
11284 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11285 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
11286 consider(
11287 dist(x, y, n.x, n.y),
11288 2,
11289 Target::Npc(npc_id, label),
11290 &mut best,
11291 );
11292 }
11293 }
11294 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11295 let d = dist(x, y, node.x, node.y);
11296 consider(
11297 d,
11298 3,
11299 Target::Node(node.id.clone(), node.label.clone()),
11300 &mut best,
11301 );
11302 }
11303
11304 match best.map(|(_, _, t)| t) {
11305 Some(Target::Bed(bed_id)) => {
11306 let name = self
11307 .state
11308 .placed_containers
11309 .iter()
11310 .find(|c| c.id == bed_id)
11311 .map(|c| c.display_name.clone())
11312 .unwrap_or_else(|| "camp bed".into());
11313 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11314 ed.lodging_container_id = Some(bed_id.clone());
11315 }
11316 self.state
11317 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
11318 }
11319 Some(Target::Container(cid)) => {
11320 let name = self
11321 .state
11322 .placed_containers
11323 .iter()
11324 .find(|c| c.id == cid)
11325 .map(|c| c.display_name.clone())
11326 .unwrap_or_else(|| "container".into());
11327 let added = self
11328 .state
11329 .worker_route_editor
11330 .as_mut()
11331 .is_some_and(|ed| ed.append_deposit_at(&cid));
11332 if added {
11333 self.state
11334 .push_log(format!("Route: + deposit at {name} ({cid})"));
11335 } else {
11336 self.state.push_log(format!(
11337 "Route: {name} already in route — selected it (d to remove)"
11338 ));
11339 }
11340 }
11341 Some(Target::Npc(npc_id, label)) => {
11342 let template = self.re_template_candidates().into_iter().next();
11345 let Some(template) = template else {
11346 self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
11347 return;
11348 };
11349 let added = self
11350 .state
11351 .worker_route_editor
11352 .as_mut()
11353 .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
11354 if added {
11355 self.state
11356 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
11357 } else {
11358 self.state.push_log(format!(
11359 "Route: {label} already sells {template} — selected it (d to remove)"
11360 ));
11361 }
11362 }
11363 Some(Target::Node(id, label)) => {
11364 let added = self
11365 .state
11366 .worker_route_editor
11367 .as_mut()
11368 .is_some_and(|ed| ed.append_harvest_node(&id));
11369 if added {
11370 self.state
11371 .push_log(format!("Route: + harvest node {label} ({id})"));
11372 } else {
11373 self.state.push_log(format!(
11374 "Route: {label} already in route — selected it (d to remove)"
11375 ));
11376 }
11377 }
11378 None => {}
11379 }
11380 }
11381
11382 pub fn worker_route_editor_select(&mut self, delta: i32) {
11383 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11384 return;
11385 };
11386 if ed.stops.is_empty() {
11387 return;
11388 }
11389 let n = ed.stops.len() as i32;
11390 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
11391 ed.selected_stop_index = next;
11392 }
11393
11394 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
11395 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11396 return;
11397 };
11398 if delta < 0 {
11399 ed.move_selected_up();
11400 } else if delta > 0 {
11401 ed.move_selected_down();
11402 }
11403 }
11404
11405 pub fn worker_route_editor_delete_selected(&mut self) {
11406 let removed = self
11407 .state
11408 .worker_route_editor
11409 .as_mut()
11410 .is_some_and(|ed| {
11411 let before = ed.stop_count();
11412 ed.remove_selected_stop();
11413 ed.stop_count() < before
11414 });
11415 if removed {
11416 self.state.push_log("Route: removed selected stop");
11417 }
11418 }
11419
11420 pub fn worker_route_editor_clear_stops(&mut self) {
11423 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11424 return;
11425 };
11426 if ed.stops.is_empty() {
11427 self.state.push_log("Route: already empty — s saves an idle worker".to_string());
11428 return;
11429 }
11430 ed.stops.clear();
11431 ed.selected_stop_index = 0;
11432 self.state
11433 .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
11434 }
11435
11436 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
11437 if self.state.pending_worker_job_ack.is_some() {
11438 anyhow::bail!("route save still pending — wait for server ack");
11439 }
11440 let Some(ed) = self.state.worker_route_editor.clone() else {
11441 anyhow::bail!("route editor not open");
11442 };
11443 let (job_yaml, idle) = if ed.stops.is_empty() {
11446 (ed.build_idle_job_yaml(), true)
11447 } else {
11448 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
11449 };
11450 let worker_id = ed.worker_instance_id.clone();
11451 let route_view = if idle {
11452 None
11453 } else {
11454 Some(ed.to_route_view())
11455 };
11456 let mode = if idle {
11457 flatland_protocol::WorkerModeView::Idle
11458 } else {
11459 flatland_protocol::WorkerModeView::JobLoop
11460 };
11461 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
11462 .state
11463 .hired_workers
11464 .iter()
11465 .find(|w| w.instance_id == worker_id)
11466 .map(|w| {
11467 (
11468 w.route.clone(),
11469 w.mode,
11470 w.step_label.clone(),
11471 w.last_error.clone(),
11472 )
11473 })
11474 .unwrap_or((
11475 None,
11476 flatland_protocol::WorkerModeView::Idle,
11477 String::new(),
11478 None,
11479 ));
11480 self.seq += 1;
11481 let seq = self.seq;
11482 self.session
11483 .submit_intent(Intent::SetWorkerJob {
11484 entity_id: self.state.entity_id,
11485 worker_instance_id: worker_id.clone(),
11486 job_yaml,
11487 seq,
11488 })
11489 .await?;
11490 self.state.intents_sent += 1;
11491 if let Some(w) = self
11492 .state
11493 .hired_workers
11494 .iter_mut()
11495 .find(|w| w.instance_id == worker_id)
11496 {
11497 w.route = route_view;
11498 w.mode = mode;
11499 w.last_error = None;
11500 if idle {
11501 w.step_label.clear();
11502 w.route_stop_index = None;
11503 }
11504 }
11505 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
11506 seq,
11507 worker_instance_id: worker_id,
11508 worker_label: ed.worker_label.clone(),
11509 idle,
11510 stop_count: ed.stops.len(),
11511 prev_route,
11512 prev_mode,
11513 prev_step_label,
11514 prev_last_error,
11515 });
11516 self.state.push_log(format!(
11517 "Route: saving for {}… (waiting for server)",
11518 ed.worker_label
11519 ));
11520 Ok(())
11522 }
11523 pub fn quest_menu_move(&mut self, delta: i32) {
11524 let n = self.state.active_quest_entries().len();
11525 if n == 0 {
11526 return;
11527 }
11528 let idx = self.state.quest_menu_index as i32;
11529 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
11530 }
11531
11532 pub fn quest_menu_page(&mut self, pages: i32) {
11533 let n = self.state.active_quest_entries().len();
11534 self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
11535 }
11536
11537 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
11538 let Some(offer) = self.state.pending_quest_offer.clone() else {
11539 anyhow::bail!("no quest offer");
11540 };
11541 self.seq += 1;
11542 let seq = self.seq;
11543 self.session
11544 .submit_intent(Intent::AcceptQuest {
11545 entity_id: self.state.entity_id,
11546 quest_id: offer.quest_id,
11547 seq,
11548 })
11549 .await?;
11550 self.state.intents_sent += 1;
11551 Ok(())
11552 }
11553
11554 pub fn quest_offer_decline(&mut self) {
11555 self.state.show_quest_offer = false;
11556 self.state.pending_quest_offer = None;
11557 if !self.state.show_npc_chat
11558 && !self.state.show_shop_menu
11559 && self.state.npc_verb_target.is_some()
11560 {
11561 self.state.show_npc_verb_menu = true;
11562 }
11563 }
11564
11565 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
11566 if !self.state.show_quest_menu {
11567 return Ok(());
11568 }
11569 let active: Vec<_> = self
11570 .state
11571 .active_quest_entries()
11572 .into_iter()
11573 .cloned()
11574 .collect();
11575 let Some(entry) = active.get(self.state.quest_menu_index) else {
11576 return Ok(());
11577 };
11578 if self.state.quest_withdraw_confirm {
11579 if !entry.can_withdraw {
11580 anyhow::bail!("quest cannot be withdrawn");
11581 }
11582 self.seq += 1;
11583 let seq = self.seq;
11584 self.session
11585 .submit_intent(Intent::WithdrawQuest {
11586 entity_id: self.state.entity_id,
11587 quest_id: entry.quest_id.clone(),
11588 seq,
11589 })
11590 .await?;
11591 self.state.intents_sent += 1;
11592 self.state.quest_withdraw_confirm = false;
11593 return Ok(());
11594 }
11595 self.seq += 1;
11596 let seq = self.seq;
11597 self.session
11598 .submit_intent(Intent::TrackQuest {
11599 entity_id: self.state.entity_id,
11600 quest_id: entry.quest_id.clone(),
11601 seq,
11602 })
11603 .await?;
11604 self.state.intents_sent += 1;
11605 Ok(())
11606 }
11607
11608 pub fn quest_request_withdraw(&mut self) {
11609 if self.state.show_quest_menu {
11610 self.state.quest_withdraw_confirm = true;
11611 }
11612 }
11613
11614 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
11615 if !self.state.is_alive() {
11616 anyhow::bail!("you are dead");
11617 }
11618 let Some(catalog) = self.state.shop_catalog.clone() else {
11619 anyhow::bail!("no shop open");
11620 };
11621 self.seq += 1;
11622 let seq = self.seq;
11623 match self.state.shop_tab {
11624 ShopTab::Buy => {
11625 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
11626 anyhow::bail!("nothing selected");
11627 };
11628 if offer.already_owned {
11629 anyhow::bail!("already owned");
11630 }
11631 self.session
11632 .submit_intent(Intent::ShopBuy {
11633 entity_id: self.state.entity_id,
11634 npc_id: catalog.npc_id.clone(),
11635 offer_id: offer.offer_id.clone(),
11636 quantity: self.state.shop_quantity,
11637 seq,
11638 })
11639 .await?;
11640 }
11641 ShopTab::Sell => {
11642 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
11643 anyhow::bail!("nothing to sell");
11644 };
11645 if line.quantity == 0 {
11646 anyhow::bail!("you have no {}", line.label);
11647 }
11648 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
11649 self.session
11650 .submit_intent(Intent::ShopSell {
11651 entity_id: self.state.entity_id,
11652 npc_id: catalog.npc_id.clone(),
11653 template_id: line.template_id.clone(),
11654 quantity,
11655 seq,
11656 })
11657 .await?;
11658 }
11659 }
11660 self.state.intents_sent += 1;
11661 Ok(())
11662 }
11663
11664 pub fn craft_menu_move(&mut self, delta: i32) {
11665 let n = self.state.blueprints.len();
11666 if n == 0 {
11667 return;
11668 }
11669 let idx = self.state.craft_menu_index as i32;
11670 let next = (idx + delta).rem_euclid(n as i32);
11671 self.state.craft_menu_index = next as usize;
11672 self.state.clamp_craft_batch_quantity();
11673 }
11674
11675 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
11676 self.state.craft_batch_adjust_quantity(delta);
11677 }
11678
11679 pub fn craft_batch_set_max(&mut self) {
11680 self.state.craft_batch_set_max();
11681 }
11682
11683 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
11684 let Some(blueprint) = self
11685 .state
11686 .blueprints
11687 .get(self.state.craft_menu_index)
11688 .cloned()
11689 else {
11690 anyhow::bail!("no blueprints known");
11691 };
11692 if !self.state.can_craft_blueprint(&blueprint) {
11693 let hint = self
11694 .state
11695 .craft_missing_hint(&blueprint)
11696 .unwrap_or_else(|| "missing materials".into());
11697 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
11698 }
11699 let count = self.state.craft_batch_quantity;
11700 let max = self.state.max_craft_batches(&blueprint);
11701 if max == 0 {
11702 anyhow::bail!("cannot craft {}", blueprint.label);
11703 }
11704 let batches = count.min(max);
11705 self.craft(&blueprint.id, Some(batches)).await?;
11706 self.state.show_craft_menu = false;
11707 Ok(())
11708 }
11709
11710 pub async fn move_by(
11711 &mut self,
11712 forward: f32,
11713 strafe: f32,
11714 vertical: f32,
11715 sprint: bool,
11716 ) -> anyhow::Result<()> {
11717 if !self.state.is_alive() {
11718 anyhow::bail!("you are dead");
11719 }
11720 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
11721 self.last_move_forward = forward;
11722 self.last_move_strafe = strafe;
11723 }
11724 self.seq += 1;
11725 self.session
11726 .submit_intent(Intent::Move {
11727 entity_id: self.state.entity_id,
11728 forward,
11729 strafe,
11730 vertical,
11731 sprint,
11732 seq: self.seq,
11733 })
11734 .await?;
11735 self.state.intents_sent += 1;
11736 Ok(())
11737 }
11738
11739 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
11740 if !self.state.connected {
11741 crate::harvest_trace!("harvest_nearest rejected: not connected");
11742 anyhow::bail!("not connected");
11743 }
11744 if !self.state.is_alive() {
11745 crate::harvest_trace!("harvest_nearest rejected: player dead");
11746 anyhow::bail!("you are dead");
11747 }
11748 if self.state.harvest_in_progress {
11749 if self.state.harvest_state_stale() {
11750 self.state.clear_harvest_state();
11751 } else {
11752 anyhow::bail!("already harvesting");
11753 }
11754 }
11755 let (px, py) = self
11756 .state
11757 .player
11758 .as_ref()
11759 .map(|p| (p.transform.position.x, p.transform.position.y))
11760 .unwrap_or((0.0, 0.0));
11761
11762 let available = self
11763 .state
11764 .resource_nodes
11765 .iter()
11766 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
11767 .count();
11768 let node_id = self
11769 .state
11770 .resource_nodes
11771 .iter()
11772 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
11773 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
11774 .min_by(|a, b| {
11775 let da = distance(px, py, a.x, a.y);
11776 let db = distance(px, py, b.x, b.y);
11777 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
11778 })
11779 .map(|n| n.id.clone());
11780
11781 let Some(node_id) = node_id else {
11782 let has_loot = self
11783 .state
11784 .ground_drops
11785 .iter()
11786 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
11787 if has_loot {
11788 return self.pickup_nearest().await;
11789 }
11790 anyhow::bail!(
11791 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
11792 );
11793 };
11794
11795 self.seq += 1;
11796 let seq = self.seq;
11797 crate::harvest_trace!(
11798 entity_id = self.state.entity_id,
11799 node_id = %node_id,
11800 seq,
11801 px,
11802 py,
11803 available_nodes = available,
11804 "submitting harvest intent"
11805 );
11806 self.session
11807 .submit_intent(Intent::Harvest {
11808 entity_id: self.state.entity_id,
11809 node_id,
11810 seq,
11811 })
11812 .await?;
11813 self.state.intents_sent += 1;
11814 self.state.harvest_in_progress = true;
11815 self.state.harvest_started_at = Some(Instant::now());
11816 self.state.push_log("Harvesting…");
11817 crate::harvest_trace!(
11818 entity_id = self.state.entity_id,
11819 seq,
11820 "harvest intent queued to session"
11821 );
11822 Ok(())
11823 }
11824
11825 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
11826 if !self.state.is_alive() {
11827 anyhow::bail!("you are dead");
11828 }
11829 let blueprint_id = self
11830 .state
11831 .blueprints
11832 .iter()
11833 .find(|bp| self.state.can_craft_blueprint(bp))
11834 .map(|bp| bp.id.clone())
11835 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
11836 self.craft(&blueprint_id, None).await
11837 }
11838
11839 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
11840 if !self.state.is_alive() {
11841 anyhow::bail!("you are dead");
11842 }
11843 self.seq += 1;
11844 self.session
11845 .submit_intent(Intent::Craft {
11846 entity_id: self.state.entity_id,
11847 blueprint_id: blueprint_id.to_string(),
11848 count,
11849 seq: self.seq,
11850 })
11851 .await?;
11852 self.state.intents_sent += 1;
11853 let (label, batches) = self
11854 .state
11855 .blueprints
11856 .iter()
11857 .find(|b| b.id == blueprint_id)
11858 .map(|b| {
11859 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
11860 (b.label.as_str(), n)
11861 })
11862 .unwrap_or((blueprint_id, count.unwrap_or(1)));
11863 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
11864 Ok(())
11865 }
11866
11867 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
11868 if !self.state.is_alive() {
11869 anyhow::bail!("you are dead");
11870 }
11871 let target_id = match self.state.nearest_interact_target() {
11872 Some(id) => id,
11873 None => {
11874 anyhow::bail!("nothing to interact with nearby");
11875 }
11876 };
11877 if self.state.npcs.iter().any(|n| n.id == target_id) {
11878 self.state.show_npc_verb_menu = true;
11879 self.state.npc_verb_target = Some(target_id);
11880 self.state.npc_verb_index = 0;
11881 return Ok(());
11882 }
11883 if self
11884 .state
11885 .hired_workers
11886 .iter()
11887 .any(|w| w.instance_id == target_id)
11888 {
11889 return self.open_workers_menu_for(&target_id).await;
11890 }
11891 if let Ok(peer_id) = target_id.parse::<EntityId>() {
11892 if self
11893 .state
11894 .hired_workers
11895 .iter()
11896 .any(|w| w.entity_id == peer_id)
11897 {
11898 if let Some(w) = self
11899 .state
11900 .hired_workers
11901 .iter()
11902 .find(|w| w.entity_id == peer_id)
11903 {
11904 let id = w.instance_id.clone();
11905 return self.open_workers_menu_for(&id).await;
11906 }
11907 }
11908 if let Some(entity) = self
11909 .state
11910 .entities
11911 .iter()
11912 .find(|e| e.id == peer_id && e.id != self.state.entity_id)
11913 {
11914 self.state
11915 .player_verbs
11916 .open_for(peer_id, &entity.label);
11917 return Ok(());
11918 }
11919 }
11920 self.seq += 1;
11921 self.session
11922 .submit_intent(Intent::Interact {
11923 entity_id: self.state.entity_id,
11924 target_id: target_id.clone(),
11925 seq: self.seq,
11926 })
11927 .await?;
11928 self.state.intents_sent += 1;
11929 Ok(())
11930 }
11931
11932 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
11934 if !self.state.is_alive() {
11935 anyhow::bail!("you are dead");
11936 }
11937 let (px, py) = self.state.player_position();
11938 let has_loot = self
11939 .state
11940 .ground_drops
11941 .iter()
11942 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
11943 if has_loot {
11944 return self.pickup_nearest().await;
11945 }
11946 if self
11947 .state
11948 .placed_containers
11949 .iter()
11950 .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
11951 {
11952 return self.pickup_nearest_container().await;
11953 }
11954
11955 if let Some(plot) = self.state.my_plot_under_player().cloned() {
11956 const SELL_WINDOW: Duration = Duration::from_millis(1200);
11958 let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
11959 && self
11960 .state
11961 .sell_plot_armed_at
11962 .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
11963 if sell_armed {
11964 return self.confirm_sell_plot_to_crown(plot.plot_id).await;
11965 }
11966 self.state.sell_plot_confirm = None;
11967 self.state.sell_plot_armed_at = None;
11968
11969 let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
11972 self.state.npcs.iter().any(|n| n.id == id)
11973 || self.state.hired_workers.iter().any(|w| w.instance_id == id)
11974 || self.state.doors.iter().any(|d| d.id == id)
11975 || self.state.interactables.iter().any(|i| {
11976 i.id == id
11977 && matches!(
11978 i.kind.as_str(),
11979 "quest_board" | "well" | "exit" | "enter"
11980 )
11981 })
11982 || id.parse::<EntityId>().is_ok_and(|eid| {
11983 self.state
11984 .entities
11985 .iter()
11986 .any(|e| e.id == eid && e.id != self.state.entity_id)
11987 })
11988 });
11989 if !blocking_interact {
11990 match self.harvest_nearest().await {
11992 Ok(()) => return Ok(()),
11993 Err(err) => {
11994 let msg = err.to_string();
11995 if !(msg.contains("no harvestable")
11996 || msg.contains("press p")
11997 || msg.contains("press f")
11998 || msg.contains("nothing"))
11999 {
12000 return Err(err);
12001 }
12002 }
12003 }
12004 return Ok(());
12005 }
12006 }
12007 if self.state.nearest_interact_target().is_some() {
12008 return self.interact_nearest().await;
12009 }
12010 if let Some((label, dist)) = self.state.nearest_quest_board() {
12013 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
12014 anyhow::bail!(
12015 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
12016 );
12017 }
12018 }
12019
12020 match self.harvest_nearest().await {
12021 Ok(()) => Ok(()),
12022 Err(err) => {
12023 let msg = err.to_string();
12024 if msg.contains("no harvestable")
12025 || msg.contains("press p")
12026 || msg.contains("press f")
12027 {
12028 anyhow::bail!(
12029 "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
12030 );
12031 }
12032 Err(err)
12033 }
12034 }
12035 }
12036
12037 pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
12039 if !self.state.is_alive() {
12040 anyhow::bail!("you are dead");
12041 }
12042 if self.state.claim_mode.is_some() {
12043 anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
12044 }
12045 let zone = self
12046 .state
12047 .free_property_zone_under_player()
12048 .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
12049 let zone_id = zone.id.clone();
12050 let label = zone
12051 .label
12052 .as_deref()
12053 .filter(|s| !s.trim().is_empty())
12054 .unwrap_or(zone.id.as_str())
12055 .to_string();
12056 self.enter_claim_mode(&zone_id);
12057 self.state
12058 .push_log(format!(
12059 "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
12060 ));
12061 Ok(())
12062 }
12063
12064 pub fn enter_claim_mode(&mut self, zone_id: &str) {
12066 let Some(zone) = self
12067 .state
12068 .property_zones
12069 .iter()
12070 .find(|z| z.id == zone_id)
12071 .cloned()
12072 else {
12073 self.state.push_log("unknown property zone");
12074 return;
12075 };
12076 self.state.sell_plot_confirm = None;
12077 self.state.sell_plot_armed_at = None;
12078 let min_area = self
12079 .state
12080 .property_plot_settings
12081 .as_ref()
12082 .map(|s| s.min_plot_area_m2)
12083 .unwrap_or(4.0)
12084 .max(1.0);
12085 let min_side = min_area.sqrt().ceil().max(1.0) as u32;
12086 let side = 4u32.max(min_side);
12087 let (px, py) = self.state.player_position();
12088 let anchor_x = px.floor();
12089 let anchor_y = py.floor();
12090 self.state.claim_mode = Some(ClaimModeState {
12091 zone_id: zone.id.clone(),
12092 width_m: side,
12093 height_m: side,
12094 anchor_x,
12095 anchor_y,
12096 });
12097 let label = zone
12098 .label
12099 .as_deref()
12100 .filter(|s| !s.trim().is_empty())
12101 .unwrap_or(zone.id.as_str());
12102 self.state.push_log(format!(
12103 "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
12104 ));
12105 }
12106
12107 pub fn cancel_claim_mode(&mut self) {
12108 if self.state.claim_mode.take().is_some() {
12109 self.state.push_log("Claim cancelled");
12110 }
12111 }
12112
12113 pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
12115 if !self.state.is_alive() {
12116 anyhow::bail!("you are dead");
12117 }
12118 if self.state.relocate_mode.is_some() {
12119 anyhow::bail!("already relocating — Enter confirm, Esc cancel");
12120 }
12121 if self.state.claim_mode.is_some() {
12122 anyhow::bail!("finish or cancel claim mode first");
12123 }
12124 let chest = self
12125 .state
12126 .placed_containers
12127 .iter()
12128 .find(|c| c.id == container_id)
12129 .cloned()
12130 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
12131 let (px, py) = self.state.player_position();
12132 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
12133 anyhow::bail!("too far from {}", chest.display_name);
12134 }
12135 if chest.locked && !chest.accessible {
12136 anyhow::bail!(
12137 "need the matching key for {} before moving it",
12138 chest.display_name
12139 );
12140 }
12141 let label = if chest.display_name.trim().is_empty() {
12142 chest.template_id.clone()
12143 } else {
12144 chest.display_name.clone()
12145 };
12146 self.state.relocate_mode = Some(RelocateModeState {
12147 container_id: chest.id.clone(),
12148 label: label.clone(),
12149 cursor_x: chest.x.floor() + 0.5,
12150 cursor_y: chest.y.floor() + 0.5,
12151 });
12152 self.state.push_log(format!(
12153 "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
12154 ));
12155 Ok(())
12156 }
12157
12158 pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
12160 let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
12161 anyhow::bail!("no chest nearby to relocate");
12162 };
12163 if chest.locked && !chest.accessible {
12164 anyhow::bail!(
12165 "need the matching key for {} before moving it",
12166 chest.display_name
12167 );
12168 }
12169 self.begin_relocate_container(&chest.id)
12172 }
12173
12174 pub fn cancel_relocate_mode(&mut self) {
12175 if self.state.relocate_mode.take().is_some() {
12176 self.state.push_log("Relocate cancelled");
12177 }
12178 }
12179
12180 pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
12181 let Some(mode) = self.state.relocate_mode.as_mut() else {
12182 return;
12183 };
12184 let max_x = self.state.world_width_m.max(1.0);
12185 let max_y = self.state.world_height_m.max(1.0);
12186 let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
12187 let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
12188 mode.cursor_x = nx.floor() + 0.5;
12189 mode.cursor_y = ny.floor() + 0.5;
12190 }
12191
12192 pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
12193 let Some(mode) = self.state.relocate_mode.as_mut() else {
12194 return;
12195 };
12196 let max_x = self.state.world_width_m.max(1.0);
12197 let max_y = self.state.world_height_m.max(1.0);
12198 mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
12199 mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
12200 }
12201
12202 pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
12203 if !self.state.is_alive() {
12204 anyhow::bail!("you are dead");
12205 }
12206 let Some(mode) = self.state.relocate_mode.clone() else {
12207 anyhow::bail!("not relocating");
12208 };
12209 let (px, py) = self.state.player_position();
12210 let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
12211 if dist > 8.0 {
12212 anyhow::bail!("destination too far (max 8 m)");
12213 }
12214 self.seq += 1;
12215 self.session
12216 .submit_intent(Intent::MovePlacedContainer {
12217 entity_id: self.state.entity_id,
12218 container_id: mode.container_id.clone(),
12219 x: mode.cursor_x,
12220 y: mode.cursor_y,
12221 seq: self.seq,
12222 })
12223 .await?;
12224 self.state.intents_sent += 1;
12225 self.state.relocate_mode = None;
12226 self.state
12227 .push_log(format!("Moving {}…", mode.label));
12228 Ok(())
12229 }
12230
12231 pub fn claim_set_preset(&mut self, w: u32, h: u32) {
12232 let Some(mode) = self.state.claim_mode.as_mut() else {
12233 return;
12234 };
12235 mode.width_m = w.max(1);
12236 mode.height_m = h.max(1);
12237 }
12238
12239 pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
12240 let Some(mode) = self.state.claim_mode.as_mut() else {
12241 return;
12242 };
12243 let w = (mode.width_m as i32 + dw).max(1) as u32;
12244 let h = (mode.height_m as i32 + dh).max(1) as u32;
12245 mode.width_m = w;
12246 mode.height_m = h;
12247 }
12248
12249 pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
12251 let Some(mode) = self.state.claim_mode.as_mut() else {
12252 return;
12253 };
12254 let max_x = self.state.world_width_m.max(1.0);
12255 let max_y = self.state.world_height_m.max(1.0);
12256 let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
12257 let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
12258 mode.anchor_x = nx.floor();
12259 mode.anchor_y = ny.floor();
12260 }
12261
12262 pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
12263 if !self.state.is_alive() {
12264 anyhow::bail!("you are dead");
12265 }
12266 let Some(mode) = self.state.claim_mode.clone() else {
12267 anyhow::bail!("not in claim mode");
12268 };
12269 let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
12270 self.state.claim_quote()
12271 else {
12272 anyhow::bail!("cannot quote claim");
12273 };
12274 if !valid {
12275 anyhow::bail!(reason);
12276 }
12277 if !can_afford {
12278 anyhow::bail!(
12279 "not enough copper (need {})",
12280 crate::currency::format_copper(purchase)
12281 );
12282 }
12283 let (x0, y0, x1, y1) = self
12284 .state
12285 .claim_footprint_rect()
12286 .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
12287 let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
12288 self.seq += 1;
12289 self.session
12290 .submit_intent(Intent::BuyPlot {
12291 entity_id: self.state.entity_id,
12292 zone_id: mode.zone_id,
12293 x0,
12294 y0,
12295 x1,
12296 y1,
12297 seq: self.seq,
12298 })
12299 .await?;
12300 self.state.intents_sent += 1;
12301 self.state.claim_mode = None;
12302 self.state
12303 .push_log(format!("Buying plot for {}", crate::currency::format_copper(purchase)));
12304 Ok(())
12305 }
12306
12307 pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
12308 if !self.state.is_alive() {
12309 anyhow::bail!("you are dead");
12310 }
12311 let zone_id = self
12312 .state
12313 .claim_mode
12314 .as_ref()
12315 .map(|m| m.zone_id.clone())
12316 .or_else(|| {
12317 self.state
12318 .free_property_zone_under_player()
12319 .map(|z| z.id.clone())
12320 })
12321 .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
12322 self.seq += 1;
12323 self.session
12324 .submit_intent(Intent::BuyPlotAllFree {
12325 entity_id: self.state.entity_id,
12326 zone_id,
12327 seq: self.seq,
12328 })
12329 .await?;
12330 self.state.intents_sent += 1;
12331 self.state.claim_mode = None;
12332 self.state.push_log("Claiming largest free plot…");
12333 Ok(())
12334 }
12335
12336 pub async fn confirm_sell_plot_to_crown(
12337 &mut self,
12338 plot_id: uuid::Uuid,
12339 ) -> anyhow::Result<()> {
12340 if !self.state.is_alive() {
12341 anyhow::bail!("you are dead");
12342 }
12343 self.seq += 1;
12344 self.session
12345 .submit_intent(Intent::SellPlotToCrown {
12346 entity_id: self.state.entity_id,
12347 plot_id,
12348 seq: self.seq,
12349 })
12350 .await?;
12351 self.state.intents_sent += 1;
12352 self.state.sell_plot_confirm = None;
12353 self.state.sell_plot_armed_at = None;
12354 self.state.push_log("Selling plot to the crown…");
12355 Ok(())
12356 }
12357
12358 pub async fn set_plot_farm_public(
12359 &mut self,
12360 plot_id: uuid::Uuid,
12361 public: bool,
12362 public_tax_discount_bps: u32,
12363 ) -> anyhow::Result<()> {
12364 self.seq += 1;
12365 self.session
12366 .submit_intent(Intent::SetPlotFarmPublic {
12367 entity_id: self.state.entity_id,
12368 plot_id,
12369 public,
12370 public_tax_discount_bps,
12371 seq: self.seq,
12372 })
12373 .await?;
12374 self.state.intents_sent += 1;
12375 Ok(())
12376 }
12377
12378 pub async fn plot_farm_allow_upsert(
12379 &mut self,
12380 plot_id: uuid::Uuid,
12381 character_id: Option<uuid::Uuid>,
12382 character_name: String,
12383 tax_discount_bps: u32,
12384 ) -> anyhow::Result<()> {
12385 self.seq += 1;
12386 self.session
12387 .submit_intent(Intent::PlotFarmAllowUpsert {
12388 entity_id: self.state.entity_id,
12389 plot_id,
12390 character_id,
12391 character_name,
12392 tax_discount_bps,
12393 seq: self.seq,
12394 })
12395 .await?;
12396 self.state.intents_sent += 1;
12397 Ok(())
12398 }
12399
12400 pub async fn plot_farm_allow_remove(
12401 &mut self,
12402 plot_id: uuid::Uuid,
12403 character_id: uuid::Uuid,
12404 ) -> anyhow::Result<()> {
12405 self.seq += 1;
12406 self.session
12407 .submit_intent(Intent::PlotFarmAllowRemove {
12408 entity_id: self.state.entity_id,
12409 plot_id,
12410 character_id,
12411 seq: self.seq,
12412 })
12413 .await?;
12414 self.state.intents_sent += 1;
12415 Ok(())
12416 }
12417
12418 pub fn open_farm_access_panel(&mut self) {
12419 let Some(plot) = self.state.my_plot_under_player() else {
12420 self.state
12421 .push_log("Stand on your deed plot to manage farm access");
12422 return;
12423 };
12424 self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
12425 self.state.farm_access_index = 0;
12426 self.state.show_farm_access = true;
12427 }
12428
12429 pub fn close_farm_access_panel(&mut self) {
12430 self.state.show_farm_access = false;
12431 self.state.farm_access_name_draft.clear();
12432 self.state.farm_access_index = 0;
12433 }
12434
12435 pub fn farm_access_move(&mut self, delta: i32) {
12436 let n = self.farm_access_row_count().max(1);
12437 let idx = self.state.farm_access_index as i32 + delta;
12438 self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
12439 }
12440
12441 pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
12442 let Some(plot) = self.state.my_plot_under_player() else {
12443 return vec![FarmAccessRow::PublicToggle];
12444 };
12445 let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
12446 for g in &plot.farm_allow {
12447 rows.push(FarmAccessRow::AllowRemove {
12448 character_id: g.character_id,
12449 label: if g.character_label.trim().is_empty() {
12450 g.character_id.to_string()[..8].to_string()
12451 } else {
12452 g.character_label.clone()
12453 },
12454 tax_discount_bps: g.tax_discount_bps,
12455 });
12456 }
12457 for e in &self.state.entities {
12458 if e.id == self.state.entity_id || e.label.trim().is_empty() {
12459 continue;
12460 }
12461 if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
12462 continue;
12463 }
12464 if self
12465 .state
12466 .npcs
12467 .iter()
12468 .any(|n| n.id == e.label || n.label == e.label)
12469 {
12470 continue;
12471 }
12472 if plot
12473 .farm_allow
12474 .iter()
12475 .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
12476 {
12477 continue;
12478 }
12479 rows.push(FarmAccessRow::NearbyAdd {
12480 name: e.label.clone(),
12481 });
12482 }
12483 rows
12484 }
12485
12486 pub fn farm_access_row_count(&self) -> usize {
12487 self.farm_access_rows().len().max(1)
12488 }
12489
12490 pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
12491 let Some(plot) = self.state.my_plot_under_player().cloned() else {
12492 self.close_farm_access_panel();
12493 return Ok(());
12494 };
12495 let rows = self.farm_access_rows();
12496 let Some(row) = rows.get(self.state.farm_access_index) else {
12497 return Ok(());
12498 };
12499 match row {
12500 FarmAccessRow::PublicToggle => {
12501 self.set_plot_farm_public(
12502 plot.plot_id,
12503 !plot.farm_public,
12504 plot.public_tax_discount_bps,
12505 )
12506 .await
12507 }
12508 FarmAccessRow::PublicDiscount => Ok(()),
12509 FarmAccessRow::AllowRemove { character_id, .. } => {
12510 self.plot_farm_allow_remove(plot.plot_id, *character_id)
12511 .await
12512 }
12513 FarmAccessRow::NearbyAdd { name } => {
12514 let disc = self
12515 .state
12516 .farm_access_discount_bps
12517 .max(plot.public_tax_discount_bps);
12518 self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
12519 .await
12520 }
12521 }
12522 }
12523
12524 pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
12525 let Some(plot) = self.state.my_plot_under_player().cloned() else {
12526 return Ok(());
12527 };
12528 let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
12529 self.state.farm_access_discount_bps = next;
12530 self.state.farm_access_index = 1;
12531 self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
12532 .await
12533 }
12534
12535 pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
12537 if self.state.farmable_plot_under_player().is_none() {
12538 anyhow::bail!("stand on a farmable plot to cultivate");
12539 }
12540 let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
12541 let (px, py) = self.state.player_position();
12542 if self
12543 .state
12544 .terrain_at(px, py)
12545 .is_some_and(|k| k == TerrainKindView::Tilled)
12546 {
12547 anyhow::bail!("already tilled — stand on bare soil and press c");
12548 }
12549 anyhow::bail!("cannot till this cell — move onto soil on your plot");
12550 };
12551 self.cultivate_at(tx, ty).await
12552 }
12553
12554 pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
12556 if self.state.farmable_plot_under_player().is_none() {
12557 anyhow::bail!("stand on a farmable plot to plant");
12558 }
12559 if !self.state.underfoot_free_tilled_plant_slot() {
12560 anyhow::bail!("stand on empty tilled soil and press p");
12561 }
12562 let seeds = self.state.farm_seed_entries();
12563 if seeds.is_empty() {
12564 anyhow::bail!("no seeds in inventory — buy seeds from Eli");
12565 }
12566 if seeds.len() == 1 {
12567 return self.plant_seeds(seeds[0].0.clone(), 1).await;
12568 }
12569 self.open_plant_menu();
12570 Ok(())
12571 }
12572
12573 pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
12574 if !self.state.is_alive() {
12575 anyhow::bail!("you are dead");
12576 }
12577 self.seq += 1;
12578 self.session
12579 .submit_intent(Intent::Cultivate {
12580 entity_id: self.state.entity_id,
12581 x,
12582 y,
12583 seq: self.seq,
12584 })
12585 .await?;
12586 self.state.intents_sent += 1;
12587 Ok(())
12588 }
12589
12590 pub async fn plant_seeds(
12591 &mut self,
12592 seed_template_id: String,
12593 quantity: u32,
12594 ) -> anyhow::Result<()> {
12595 if !self.state.is_alive() {
12596 anyhow::bail!("you are dead");
12597 }
12598 self.seq += 1;
12599 self.session
12600 .submit_intent(Intent::PlantSeeds {
12601 entity_id: self.state.entity_id,
12602 seed_template_id: seed_template_id.clone(),
12603 quantity,
12604 seq: self.seq,
12605 })
12606 .await?;
12607 self.state.intents_sent += 1;
12608 self.state
12609 .push_log(format!("Planting {quantity}× {seed_template_id}…"));
12610 Ok(())
12611 }
12612
12613 pub fn open_plant_menu(&mut self) {
12614 if self.state.farm_seed_entries().is_empty() {
12615 self.state.push_log("No seeds in inventory to plant");
12616 return;
12617 }
12618 self.state.show_plant_menu = true;
12619 self.state.plant_menu_index = 0;
12620 self.state.plant_quantity = 1;
12621 self.state.clamp_plant_menu();
12622 }
12623
12624 pub fn close_plant_menu(&mut self) {
12625 self.state.show_plant_menu = false;
12626 }
12627
12628 pub fn plant_menu_move(&mut self, delta: i32) {
12629 let n = self.state.farm_seed_entries().len();
12630 if n == 0 {
12631 return;
12632 }
12633 let idx = self.state.plant_menu_index as i32 + delta;
12634 self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
12635 self.state.clamp_plant_menu();
12636 }
12637
12638 pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
12639 let next = self.state.plant_quantity as i32 + delta;
12640 self.state.plant_quantity = next.max(1) as u32;
12641 self.state.clamp_plant_menu();
12642 }
12643
12644 pub fn plant_menu_set_quantity_max(&mut self) {
12645 if let Some((_, max, _)) = self.state.plant_menu_selection() {
12646 self.state.plant_quantity = max;
12647 }
12648 self.state.clamp_plant_menu();
12649 }
12650
12651 pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
12652 let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
12653 self.close_plant_menu();
12654 anyhow::bail!("no seeds to plant");
12655 };
12656 self.close_plant_menu();
12657 self.plant_seeds(seed, qty).await?;
12658 self.state.push_log(format!("Planted {qty}× {label}"));
12659 Ok(())
12660 }
12661
12662 pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
12665 if !self.state.is_alive() {
12666 anyhow::bail!("you are dead");
12667 }
12668 let binding = self
12669 .state
12670 .hotbar_ability(slot)
12671 .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
12672 .to_string();
12673 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
12674 let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
12675 if qty == 0 {
12676 anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
12677 }
12678 return self.use_item(template_id).await;
12679 }
12680 let ability_id = binding;
12681 let target = if ability_id == "heal_touch" {
12682 Some(
12683 self.state
12684 .target_for_slot(2)
12685 .unwrap_or(self.state.entity_id),
12686 )
12687 } else {
12688 self.state
12689 .target_for_slot(1)
12690 .or_else(|| self.state.target_for_slot(2))
12691 };
12692 let Some(target_id) = target else {
12693 anyhow::bail!("no target — Tab to select, then press the hotbar key");
12694 };
12695 self.cast_ability(&ability_id, Some(target_id)).await
12696 }
12697
12698 pub async fn set_hotbar_slot(
12701 &mut self,
12702 slot: u8,
12703 ability_id: Option<&str>,
12704 ) -> anyhow::Result<()> {
12705 if !self.state.is_alive() {
12706 anyhow::bail!("you are dead");
12707 }
12708 if !(1..=9).contains(&slot) {
12709 anyhow::bail!("hotbar slot must be 1–9");
12710 }
12711 let ability_id = ability_id
12712 .map(str::trim)
12713 .filter(|id| !id.is_empty())
12714 .map(str::to_string);
12715 self.seq += 1;
12716 self.session
12717 .submit_intent(Intent::SetHotbarSlot {
12718 entity_id: self.state.entity_id,
12719 slot,
12720 ability_id: ability_id.clone(),
12721 seq: self.seq,
12722 })
12723 .await?;
12724 self.state.intents_sent += 1;
12725 let idx = (slot - 1) as usize;
12726 if self.state.hotbar.len() < 9 {
12727 self.state.hotbar.resize(9, None);
12728 }
12729 if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
12730 *slot_mut = ability_id.clone();
12731 }
12732 match ability_id {
12733 Some(id) => {
12734 let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
12735 format!("use {tid}")
12736 } else {
12737 id
12738 };
12739 self.state.push_log(format!("Hotbar {slot} → {label}"))
12740 }
12741 None => self.state.push_log(format!("Hotbar {slot} cleared")),
12742 }
12743 Ok(())
12744 }
12745
12746 pub fn npc_verb_options(&self) -> Vec<&'static str> {
12747 self.state.npc_verb_options()
12748 }
12749
12750 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
12751 let Some(npc_id) = self.state.npc_verb_target.clone() else {
12752 return Ok(());
12753 };
12754 let options = self.npc_verb_options();
12755 let choice = options
12756 .get(self.state.npc_verb_index)
12757 .copied()
12758 .unwrap_or("Talk");
12759 self.seq += 1;
12760 match choice {
12761 "Trade" | "Bank" | "Storage" | "Market" => {
12762 self.session
12763 .submit_intent(Intent::Interact {
12764 entity_id: self.state.entity_id,
12765 target_id: npc_id,
12766 seq: self.seq,
12767 })
12768 .await?;
12769 }
12770 _ => {
12771 self.session
12772 .submit_intent(Intent::NpcTalkOpen {
12773 entity_id: self.state.entity_id,
12774 npc_id,
12775 seq: self.seq,
12776 })
12777 .await?;
12778 }
12779 }
12780 self.state.intents_sent += 1;
12781 Ok(())
12782 }
12783
12784 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
12785 let Some(chat) = self.state.npc_chat.clone() else {
12786 return Ok(());
12787 };
12788 let message = chat.input.trim().to_string();
12789 if message.is_empty() || chat.pending {
12790 return Ok(());
12791 }
12792 if let Some(c) = self.state.npc_chat.as_mut() {
12793 c.lines.push(format!("You: {message}"));
12794 c.input.clear();
12795 c.pending = true;
12796 }
12797 self.seq += 1;
12798 self.session
12799 .submit_intent(Intent::NpcTalkSay {
12800 entity_id: self.state.entity_id,
12801 npc_id: chat.npc_id,
12802 message,
12803 seq: self.seq,
12804 })
12805 .await?;
12806 self.state.intents_sent += 1;
12807 Ok(())
12808 }
12809
12810 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
12811 let return_to_verbs = self.state.npc_verb_target.is_some();
12812 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
12813 self.state.show_npc_chat = false;
12814 if return_to_verbs {
12815 self.state.show_npc_verb_menu = true;
12816 }
12817 return Ok(());
12818 };
12819 self.seq += 1;
12820 self.session
12821 .submit_intent(Intent::NpcTalkClose {
12822 entity_id: self.state.entity_id,
12823 npc_id,
12824 seq: self.seq,
12825 })
12826 .await?;
12827 self.state.intents_sent += 1;
12828 self.state.show_npc_chat = false;
12829 self.state.npc_chat = None;
12830 if return_to_verbs {
12831 self.state.show_npc_verb_menu = true;
12832 }
12833 Ok(())
12834 }
12835
12836 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
12838 if self.state.show_quest_offer
12839 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
12840 {
12841 self.quest_offer_decline();
12842 return Ok(());
12843 }
12844 if self.state.show_npc_chat {
12845 return self.npc_talk_close().await;
12846 }
12847 if self.state.show_shop_menu {
12848 return self.back_from_shop_menu().await;
12849 }
12850 if self.state.bank_panel.is_some() {
12851 if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
12852 self.bank_transfer_back();
12853 return Ok(());
12854 }
12855 return self.close_bank_panel().await;
12856 }
12857 if self.state.storage_panel.is_some() {
12858 if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
12859 self.storage_ui_back();
12860 return Ok(());
12861 }
12862 return self.close_storage_panel().await;
12863 }
12864 if self.state.market_panel.is_some() {
12865 if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
12866 self.market_ui_back();
12867 return Ok(());
12868 }
12869 if self.state.market_buy_confirm.is_some() {
12870 self.state.market_buy_confirm = None;
12871 return Ok(());
12872 }
12873 return self.close_market_panel().await;
12874 }
12875 if self.state.show_npc_verb_menu {
12876 self.state.show_npc_verb_menu = false;
12877 self.state.npc_verb_target = None;
12878 }
12879 Ok(())
12880 }
12881
12882 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
12883 self.seq += 1;
12884 self.session
12885 .submit_intent(Intent::TestDamage {
12886 entity_id: self.state.entity_id,
12887 amount,
12888 seq: self.seq,
12889 })
12890 .await?;
12891 self.state.intents_sent += 1;
12892 Ok(())
12893 }
12894
12895 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
12896 self.cycle_combat_target_slot(1, reverse).await
12897 }
12898
12899 pub async fn cycle_combat_target_slot(
12900 &mut self,
12901 slot_index: u8,
12902 reverse: bool,
12903 ) -> anyhow::Result<()> {
12904 if !self.state.is_alive() {
12905 anyhow::bail!("you are dead");
12906 }
12907 let candidates = self.state.candidates_for_slot(slot_index);
12908 if candidates.is_empty() {
12909 anyhow::bail!("no targets nearby");
12910 }
12911 let current = self.state.target_for_slot(slot_index);
12912 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
12913 let next_idx = match idx {
12914 None => 0,
12915 Some(i) if reverse => {
12916 if i == 0 {
12917 candidates.len() - 1
12918 } else {
12919 i - 1
12920 }
12921 }
12922 Some(i) => (i + 1) % candidates.len(),
12923 };
12924 if idx == Some(next_idx) && candidates.len() == 1 {
12925 self.clear_combat_target_slot(slot_index).await?;
12926 return Ok(());
12927 }
12928 let (target_id, label) = candidates[next_idx].clone();
12929 self.set_combat_target_slot(slot_index, target_id, &label)
12930 .await
12931 }
12932
12933 pub async fn set_combat_target_slot(
12934 &mut self,
12935 slot_index: u8,
12936 target_id: EntityId,
12937 label: &str,
12938 ) -> anyhow::Result<()> {
12939 if !self.state.is_alive() {
12940 anyhow::bail!("you are dead");
12941 }
12942 self.seq += 1;
12943 self.session
12944 .submit_intent(Intent::SetTargetSlot {
12945 entity_id: self.state.entity_id,
12946 slot_index,
12947 target_id,
12948 seq: self.seq,
12949 })
12950 .await?;
12951 self.state.intents_sent += 1;
12952 if slot_index == 1 {
12953 self.state.combat_target = Some(target_id);
12954 self.state.combat_target_label = Some(label.to_string());
12955 }
12956 self.state
12957 .push_log(format!("Slot {slot_index} target: {label}"));
12958 Ok(())
12959 }
12960
12961 pub async fn set_combat_target(
12962 &mut self,
12963 target_id: EntityId,
12964 label: &str,
12965 ) -> anyhow::Result<()> {
12966 self.set_combat_target_slot(1, target_id, label).await
12967 }
12968
12969 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
12970 if slot_index == 1 && self.state.combat_target.is_none() {
12971 return Ok(());
12972 }
12973 self.seq += 1;
12974 self.session
12975 .submit_intent(Intent::ClearTargetSlot {
12976 entity_id: self.state.entity_id,
12977 slot_index,
12978 seq: self.seq,
12979 })
12980 .await?;
12981 if slot_index == 1 {
12982 self.state.combat_target = None;
12983 self.state.combat_target_label = None;
12984 }
12985 self.state.intents_sent += 1;
12986 self.state
12987 .push_log(format!("Slot {slot_index} target cleared"));
12988 Ok(())
12989 }
12990
12991 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
12992 self.clear_combat_target_slot(1).await
12993 }
12994
12995 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
12996 if !self.state.is_alive() {
12997 anyhow::bail!("you are dead");
12998 }
12999 self.seq += 1;
13000 self.session
13001 .submit_intent(Intent::AdvanceRotation {
13002 entity_id: self.state.entity_id,
13003 slot_index,
13004 seq: self.seq,
13005 })
13006 .await?;
13007 self.state.intents_sent += 1;
13008 Ok(())
13009 }
13010
13011 pub async fn assign_slot_preset(
13012 &mut self,
13013 slot_index: u8,
13014 preset_id: &str,
13015 ) -> anyhow::Result<()> {
13016 if !self.state.is_alive() {
13017 anyhow::bail!("you are dead");
13018 }
13019 self.seq += 1;
13020 self.session
13021 .submit_intent(Intent::AssignSlotPreset {
13022 entity_id: self.state.entity_id,
13023 slot_index,
13024 preset_id: preset_id.to_string(),
13025 seq: self.seq,
13026 })
13027 .await?;
13028 self.state.intents_sent += 1;
13029 if let Some(slot) = self
13030 .state
13031 .combat_slots
13032 .iter_mut()
13033 .find(|s| s.slot_index == slot_index)
13034 {
13035 slot.preset_id = Some(preset_id.to_string());
13036 if let Some(preset) = self
13037 .state
13038 .rotation_presets
13039 .iter()
13040 .find(|p| p.id == preset_id)
13041 {
13042 slot.preset_label = Some(preset.label.clone());
13043 slot.rotation = preset.abilities.clone();
13044 slot.rotation_index = 0;
13045 }
13046 }
13047 self.state
13048 .push_log(format!("T{slot_index} loadout → {preset_id}"));
13049 Ok(())
13050 }
13051
13052 pub async fn cast_ability(
13053 &mut self,
13054 ability_id: &str,
13055 target_id: Option<EntityId>,
13056 ) -> anyhow::Result<()> {
13057 if !self.state.is_alive() {
13058 anyhow::bail!("you are dead");
13059 }
13060 let target_id = target_id
13061 .or_else(|| self.state.target_for_slot(2))
13062 .or_else(|| self.state.target_for_slot(1))
13063 .unwrap_or(self.state.entity_id);
13064 self.seq += 1;
13065 self.session
13066 .submit_intent(Intent::Cast {
13067 entity_id: self.state.entity_id,
13068 ability_id: ability_id.to_string(),
13069 target_id,
13070 seq: self.seq,
13071 })
13072 .await?;
13073 self.state.intents_sent += 1;
13074 self.state
13075 .push_log(format!("Cast {ability_id} → {target_id}"));
13076 Ok(())
13077 }
13078
13079 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
13080 self.seq += 1;
13081 self.session
13082 .submit_intent(Intent::UpsertRotationPreset {
13083 entity_id: self.state.entity_id,
13084 preset: preset.clone(),
13085 seq: self.seq,
13086 })
13087 .await?;
13088 self.state.intents_sent += 1;
13089 if let Some(existing) = self
13090 .state
13091 .rotation_presets
13092 .iter_mut()
13093 .find(|p| p.id == preset.id)
13094 {
13095 *existing = preset.clone();
13096 } else {
13097 self.state.rotation_presets.push(preset.clone());
13098 }
13099 for slot in &mut self.state.combat_slots {
13100 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
13101 slot.preset_label = Some(preset.label.clone());
13102 slot.rotation = preset.abilities.clone();
13103 }
13104 }
13105 self.state
13106 .push_log(format!("Saved rotation: {}", preset.label));
13107 Ok(())
13108 }
13109
13110 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
13111 self.seq += 1;
13112 self.session
13113 .submit_intent(Intent::DeleteRotationPreset {
13114 entity_id: self.state.entity_id,
13115 preset_id: preset_id.to_string(),
13116 seq: self.seq,
13117 })
13118 .await?;
13119 self.state.intents_sent += 1;
13120 self.state.rotation_presets.retain(|p| p.id != preset_id);
13121 for slot in &mut self.state.combat_slots {
13122 if slot.preset_id.as_deref() == Some(preset_id) {
13123 slot.preset_id = None;
13124 slot.preset_label = None;
13125 slot.rotation.clear();
13126 slot.rotation_index = 0;
13127 }
13128 }
13129 self.state
13130 .push_log(format!("Deleted rotation: {preset_id}"));
13131 Ok(())
13132 }
13133
13134 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
13135 if !self.state.is_alive() {
13136 anyhow::bail!("you are dead");
13137 }
13138 let enabled = !self
13139 .state
13140 .combat_slots
13141 .iter()
13142 .find(|s| s.slot_index == slot_index)
13143 .map(|s| s.auto_enabled)
13144 .unwrap_or(false);
13145 self.seq += 1;
13146 self.session
13147 .submit_intent(Intent::SetAutoAttack {
13148 entity_id: self.state.entity_id,
13149 slot_index,
13150 enabled,
13151 seq: self.seq,
13152 })
13153 .await?;
13154 if slot_index == 1 {
13155 self.state.auto_attack = enabled;
13156 }
13157 self.state.intents_sent += 1;
13158 self.state.push_log(format!(
13159 "T{slot_index} auto {}",
13160 if enabled { "ON" } else { "OFF" }
13161 ));
13162 Ok(())
13163 }
13164
13165 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
13166 if !self.state.connected {
13167 anyhow::bail!("not connected");
13168 }
13169 if !self.state.is_alive() {
13170 anyhow::bail!("you are dead");
13171 }
13172 let (px, py) = self.state.player_position();
13173 if self
13174 .state
13175 .ground_drops
13176 .iter()
13177 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
13178 {
13179 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
13180 }
13181 self.seq += 1;
13182 self.session
13183 .submit_intent(Intent::Pickup {
13184 entity_id: self.state.entity_id,
13185 drop_id: None,
13186 seq: self.seq,
13187 })
13188 .await?;
13189 self.state.intents_sent += 1;
13190 Ok(())
13191 }
13192
13193 pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
13194 self.toggle_auto_attack_slot(1).await
13195 }
13196
13197 pub async fn dodge(&mut self) -> anyhow::Result<()> {
13198 if !self.state.is_alive() {
13199 anyhow::bail!("you are dead");
13200 }
13201 self.seq += 1;
13202 self.session
13203 .submit_intent(Intent::Dodge {
13204 entity_id: self.state.entity_id,
13205 seq: self.seq,
13206 })
13207 .await?;
13208 self.state.intents_sent += 1;
13209 self.state.push_log("Dodge!");
13210 Ok(())
13211 }
13212
13213 pub async fn lunge(&mut self) -> anyhow::Result<()> {
13214 if !self.state.is_alive() {
13215 anyhow::bail!("you are dead");
13216 }
13217 let (forward, strafe) = self.last_move_axes();
13218 self.seq += 1;
13219 self.session
13220 .submit_intent(Intent::Lunge {
13221 entity_id: self.state.entity_id,
13222 forward,
13223 strafe,
13224 seq: self.seq,
13225 })
13226 .await?;
13227 self.state.intents_sent += 1;
13228 self.state.push_log("Lunge!");
13229 Ok(())
13230 }
13231
13232 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
13233 if !self.state.is_alive() {
13234 anyhow::bail!("you are dead");
13235 }
13236 self.seq += 1;
13237 self.session
13238 .submit_intent(Intent::DirectionalJump {
13239 entity_id: self.state.entity_id,
13240 forward,
13241 strafe,
13242 seq: self.seq,
13243 })
13244 .await?;
13245 self.state.intents_sent += 1;
13246 self.state.push_log("Jump!");
13247 Ok(())
13248 }
13249
13250 pub fn last_move_axes(&self) -> (f32, f32) {
13252 (self.last_move_forward, self.last_move_strafe)
13253 }
13254
13255 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
13256 if !self.state.is_alive() {
13257 anyhow::bail!("you are dead");
13258 }
13259 self.seq += 1;
13260 self.session
13261 .submit_intent(Intent::Block {
13262 entity_id: self.state.entity_id,
13263 enabled,
13264 seq: self.seq,
13265 })
13266 .await?;
13267 self.state.intents_sent += 1;
13268 if enabled {
13269 self.state.push_log("Blocking");
13270 }
13271 Ok(())
13272 }
13273
13274 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
13275 if !self.state.is_alive() {
13276 anyhow::bail!("you are dead");
13277 }
13278 self.seq += 1;
13279 self.session
13280 .submit_intent(Intent::EquipMainhand {
13281 entity_id: self.state.entity_id,
13282 template_id,
13283 instance_id: None,
13284 seq: self.seq,
13285 })
13286 .await?;
13287 self.state.intents_sent += 1;
13288 Ok(())
13289 }
13290
13291 pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
13293 let idx = self.state.equip_menu_index;
13294 let slots = equip_paperdoll_rows(&self.state);
13295 let Some(row) = slots.get(idx) else {
13296 return Ok(());
13297 };
13298 match row {
13299 EquipPaperdollRow::Body { slot, filled } => {
13300 if *filled {
13301 self.equip_worn(*slot, None).await
13302 } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
13303 self.equip_worn(*slot, Some(inst)).await
13304 } else {
13305 self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
13306 Ok(())
13307 }
13308 }
13309 EquipPaperdollRow::Mainhand { filled } => {
13310 if *filled {
13311 self.unequip_mainhand().await
13312 } else if let Some(tid) = first_inventory_weapon(&self.state) {
13313 self.equip_mainhand(Some(tid)).await
13314 } else {
13315 self.state.push_log("No weapon in inventory".to_string());
13316 Ok(())
13317 }
13318 }
13319 EquipPaperdollRow::Offhand { filled, locked } => {
13320 if *locked {
13321 self.state
13322 .push_log("Offhand locked — two-handed weapon equipped".to_string());
13323 Ok(())
13324 } else if *filled {
13325 self.unequip_offhand().await
13326 } else if let Some(tid) = first_inventory_offhand(&self.state) {
13327 self.equip_offhand(Some(tid)).await
13328 } else {
13329 self.state
13330 .push_log("No offhand item in inventory".to_string());
13331 Ok(())
13332 }
13333 }
13334 }
13335 }
13336
13337 pub async fn say(
13338 &mut self,
13339 channel: flatland_protocol::ChatChannel,
13340 text: &str,
13341 ) -> anyhow::Result<()> {
13342 self.say_to(channel, text, None).await
13343 }
13344
13345 pub async fn say_to(
13346 &mut self,
13347 channel: flatland_protocol::ChatChannel,
13348 text: &str,
13349 to_entity: Option<EntityId>,
13350 ) -> anyhow::Result<()> {
13351 self.seq += 1;
13352 self.session
13353 .submit_intent(Intent::Say {
13354 entity_id: self.state.entity_id,
13355 channel,
13356 text: text.to_string(),
13357 to_entity,
13358 seq: self.seq,
13359 })
13360 .await?;
13361 self.state.intents_sent += 1;
13362 Ok(())
13363 }
13364
13365 pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
13366 let Some(peer) = self.state.player_verbs.target_entity else {
13367 return Ok(());
13368 };
13369 let label = self.state.player_verbs.target_label.clone();
13370 let choice = crate::social::PlayerVerbState::options()
13371 .get(self.state.player_verbs.index)
13372 .copied()
13373 .unwrap_or("Whisper");
13374 self.state.player_verbs.close();
13375 match choice {
13376 "Trade" => {
13377 self.seq += 1;
13380 self.session
13381 .submit_intent(Intent::TradeRequest {
13382 entity_id: self.state.entity_id,
13383 peer_entity_id: peer,
13384 seq: self.seq,
13385 })
13386 .await?;
13387 self.state.intents_sent += 1;
13388 self.state
13389 .social_chat
13390 .push_system(format!("Trade request sent to {label} — waiting for accept"));
13391 }
13392 "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
13393 _ => self.state.social_chat.focus_nearby(),
13394 }
13395 Ok(())
13396 }
13397
13398 pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
13399 let Some(pending) = self.state.social_chat.pending_trade.take() else {
13400 return Ok(());
13401 };
13402 self.seq += 1;
13403 self.session
13404 .submit_intent(Intent::TradeRespond {
13405 entity_id: self.state.entity_id,
13406 peer_entity_id: pending.from_entity,
13407 accept,
13408 seq: self.seq,
13409 })
13410 .await?;
13411 self.state.intents_sent += 1;
13412 if accept {
13413 self.state
13414 .social_chat
13415 .push_system(format!("Accepted trade with {}", pending.from_name));
13416 } else {
13417 self.state
13418 .social_chat
13419 .push_system(format!("Declined trade with {}", pending.from_name));
13420 }
13421 Ok(())
13422 }
13423
13424 pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
13425 let text = self.state.social_chat.buffer.trim().to_string();
13426 if text.is_empty() {
13427 return Ok(());
13428 }
13429 let thread = self.state.social_chat.thread;
13430 let channel = thread.channel();
13431 let to = thread.to_entity();
13432 self.state.social_chat.buffer.clear();
13433 self.say_to(channel, &text, to).await
13434 }
13435
13436 pub async fn trade_present_selected(
13437 &mut self,
13438 item_instance_id: uuid::Uuid,
13439 ) -> anyhow::Result<()> {
13440 self.trade_present_quantity(item_instance_id, None).await
13441 }
13442
13443 pub async fn trade_present_quantity(
13444 &mut self,
13445 item_instance_id: uuid::Uuid,
13446 quantity: Option<u32>,
13447 ) -> anyhow::Result<()> {
13448 self.seq += 1;
13449 self.session
13450 .submit_intent(Intent::TradePresent {
13451 entity_id: self.state.entity_id,
13452 item_instance_id,
13453 quantity,
13454 seq: self.seq,
13455 })
13456 .await?;
13457 self.state.intents_sent += 1;
13458 self.state.trade_ui.qty_entry = None;
13459 self.state.trade_ui.picking_inventory = false;
13460 Ok(())
13461 }
13462
13463 pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
13465 if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
13466 let qty = self.state.trade_ui.present_quantity();
13467 return self
13468 .trade_present_quantity(entry.item_instance_id, qty)
13469 .await;
13470 }
13471 if !self.state.trade_ui.picking_inventory {
13472 return Ok(());
13473 }
13474 let Some(stack) = self
13475 .state
13476 .inventory_stacks
13477 .get(self.state.trade_ui.inventory_index)
13478 .cloned()
13479 else {
13480 return Ok(());
13481 };
13482 let Some(id) = stack.item_instance_id else {
13483 return Ok(());
13484 };
13485 let label = stack
13486 .display_name
13487 .clone()
13488 .unwrap_or_else(|| stack.template_id.clone());
13489 if stack.quantity <= 1 {
13490 self.trade_present_quantity(id, Some(1)).await
13491 } else {
13492 self.state
13493 .trade_ui
13494 .begin_qty_entry(id, label, stack.quantity);
13495 Ok(())
13496 }
13497 }
13498
13499 pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
13500 self.seq += 1;
13501 self.session
13502 .submit_intent(Intent::TradeSetReady {
13503 entity_id: self.state.entity_id,
13504 ready,
13505 seq: self.seq,
13506 })
13507 .await?;
13508 self.state.intents_sent += 1;
13509 Ok(())
13510 }
13511
13512 pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
13513 self.seq += 1;
13514 self.session
13515 .submit_intent(Intent::TradeCancel {
13516 entity_id: self.state.entity_id,
13517 seq: self.seq,
13518 })
13519 .await?;
13520 self.state.intents_sent += 1;
13521 self.state.trade_ui.close();
13522 Ok(())
13523 }
13524
13525 pub async fn destroy_whisper_stone(
13526 &mut self,
13527 item_instance_id: uuid::Uuid,
13528 ) -> anyhow::Result<()> {
13529 self.seq += 1;
13530 self.session
13531 .submit_intent(Intent::DestroyWhisperStone {
13532 entity_id: self.state.entity_id,
13533 item_instance_id,
13534 seq: self.seq,
13535 })
13536 .await?;
13537 self.state.intents_sent += 1;
13538 Ok(())
13539 }
13540
13541 pub async fn stop(&mut self) -> anyhow::Result<()> {
13542 self.seq += 1;
13543 self.session
13544 .submit_intent(Intent::Stop {
13545 entity_id: self.state.entity_id,
13546 seq: self.seq,
13547 })
13548 .await?;
13549 self.state.intents_sent += 1;
13550 Ok(())
13551 }
13552
13553 pub fn disconnect(&self) {
13554 self.session.disconnect();
13555 }
13556}
13557
13558fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
13559 let dx = ax - bx;
13560 let dy = ay - by;
13561 (dx * dx + dy * dy).sqrt()
13562}
13563
13564#[cfg(test)]
13565mod tests {
13566 use std::collections::BTreeMap;
13567
13568 use super::*;
13569 use flatland_protocol::{
13570 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
13571 };
13572
13573 fn sample_state() -> GameState {
13574 let mut state = GameState {
13575 session_id: 1,
13576 entity_id: 1,
13577 character_id: None,
13578 tick: 0,
13579 chunk_rev: 0,
13580 content_rev: 0,
13581 publish_rev: 0,
13582 entities: vec![EntityState {
13583 id: 1,
13584 label: "You".into(),
13585 transform: Transform {
13586 position: WorldCoord::surface(128.0, 128.0),
13587 yaw: 0.0,
13588 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
13589 },
13590 vitals: None,
13591 attributes: None,
13592 skills: None,
13593 inside_building: None,
13594 tile_id: None,
13595 paperdoll_ref: None,
13596 presentation_state: None,
13597 sprite_mode: None,
13598 progression_xp: None,
13599 combat_cues: vec![],
13600 }],
13601 player: None,
13602 resource_nodes: vec![ResourceNodeView {
13603 id: "oak-1".into(),
13604 label: "Oak".into(),
13605 x: 130.0,
13606 y: 128.0,
13607 z: 0.0,
13608 item_template: "oak_log".into(),
13609 state: ResourceNodeState::Available,
13610 blocking: true,
13611 blocking_radius_m: 0.8,
13612 tile_id: None,
13613 yaw: 0.0,
13614 pitch: 0.0,
13615 roll: 0.0,
13616 draw_scale: 1.0,
13617 sprite_mode: None,
13618 growth_progress: None,
13619 presentation_state: None,
13620 channel_start_tick: None,
13621 channel_end_tick: None,
13622 harvest_drop_templates: vec![],
13623 }],
13624 ground_drops: vec![],
13625 placed_containers: vec![],
13626 buildings: vec![BuildingView {
13627 id: "broker-hut".into(),
13628 label: "Broker".into(),
13629 x: 148.0,
13630 y: 118.0,
13631 width_m: 8.0,
13632 depth_m: 6.0,
13633 interior_blueprint: Some("broker_hut".into()),
13634 tags: vec![],
13635 market_boundary_zone_ids: vec![],
13636 market_max_volume: None,
13637 wall_set: None,
13638 roof_set: None,
13639 }],
13640 doors: vec![flatland_protocol::DoorView {
13641 id: "door-1".into(),
13642 building_id: "broker-hut".into(),
13643 x: 148.0,
13644 y: 118.0,
13645 open: false,
13646 portal: Some("front".into()),
13647 }],
13648 interior_map: None,
13649 npcs: vec![],
13650 blueprints: vec![],
13651 world_x0: 0.0,
13652 world_y0: 0.0,
13653 world_width_m: 256.0,
13654 world_height_m: 256.0,
13655 terrain_zones: Vec::new(),
13656 z_platforms: Vec::new(),
13657 z_transitions: Vec::new(),
13658 world_clock: flatland_protocol::WorldClock::default(),
13659 inventory: std::collections::HashMap::new(),
13660 inventory_hints: std::collections::HashMap::new(),
13661 logs: VecDeque::new(),
13662 intents_sent: 0,
13663 ticks_received: 0,
13664 connected: true,
13665 disconnect_reason: None,
13666 show_stats: false,
13667 hud_log_hidden: false,
13668 show_equip_menu: false,
13669 equip_menu_index: 0,
13670 show_craft_menu: false,
13671 craft_menu_index: 0,
13672 craft_batch_quantity: 1,
13673 show_shop_menu: false,
13674 shop_catalog: None,
13675 bank_panel: None,
13676 bank_menu_index: 0,
13677 bank_ui_mode: BankUiMode::Menu,
13678 storage_panel: None,
13679 market_panel: None,
13680 market_menu_index: 0,
13681 market_filter: String::new(),
13682 market_filter_focused: false,
13683 market_category_filter: None,
13684 market_buy_confirm: None,
13685 market_ui_mode: MarketUiMode::Browse,
13686 storage_menu_index: 0,
13687 storage_ui_mode: StorageUiMode::Menu,
13688 shop_tab: ShopTab::default(),
13689 shop_menu_index: 0,
13690 shop_quantity: 1,
13691 shop_trade_log: VecDeque::new(),
13692 show_npc_verb_menu: false,
13693 npc_verb_target: None,
13694 npc_verb_index: 0,
13695 player_verbs: crate::social::PlayerVerbState::default(),
13696 social_chat: crate::social::SocialChatState::default(),
13697 trade_ui: crate::social::TradeUiState::default(),
13698 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
13699 show_npc_chat: false,
13700 npc_chat: None,
13701 show_inventory_menu: false,
13702 inventory_menu_index: 0,
13703 inventory_tab: InventoryTab::OnPerson,
13704 inventory_filter: String::new(),
13705 inventory_filter_focused: false,
13706 show_move_picker: false,
13707 show_rename_prompt: false,
13708 show_worker_rename: false,
13709 rename_buffer: String::new(),
13710 move_picker_index: 0,
13711 move_picker: None,
13712 show_grant_picker: false,
13713 grant_picker_index: 0,
13714 grant_picker: None,
13715 show_destroy_picker: false,
13716 destroy_confirm_pending: false,
13717 destroy_picker: None,
13718 combat_target: None,
13719 combat_target_label: None,
13720 combat_fx: Vec::new(),
13721 property_zones: Vec::new(),
13722 tax_zones: Vec::new(),
13723 growth_zones: Vec::new(),
13724 biome_zones: Vec::new(),
13725 property_plots: Vec::new(),
13726 property_plot_settings: None,
13727 claim_mode: None,
13728 relocate_mode: None,
13729 sell_plot_confirm: None,
13730 sell_plot_armed_at: None,
13731 show_plant_menu: false,
13732 plant_menu_index: 0,
13733 show_farm_access: false,
13734 farm_access_name_draft: String::new(),
13735 farm_access_discount_bps: 0,
13736 farm_access_index: 0,
13737 plant_quantity: 1,
13738 in_combat: false,
13739 auto_attack: true,
13740 combat_has_los: false,
13741 attack_cd_ticks: 0,
13742 gcd_ticks: 0,
13743 weapon_ability_id: "unarmed".into(),
13744 mainhand_template_id: None,
13745 mainhand_label: None,
13746 offhand_template_id: None,
13747 offhand_label: None,
13748 mainhand_hand_slots: 1,
13749 defense: None,
13750 worn: BTreeMap::new(),
13751 carry_mass: 0.0,
13752 carry_mass_max: 0.0,
13753 encumbrance: flatland_protocol::EncumbranceState::Light,
13754 inventory_stacks: Vec::new(),
13755 keychain_stacks: Vec::new(),
13756 whisper_pouch_stacks: Vec::new(),
13757 combat_target_detail: None,
13758 statuses: Vec::new(),
13759 cast_progress: None,
13760 timed_channel: None,
13761 ability_cooldowns: Vec::new(),
13762 blocking_active: false,
13763 max_target_slots: 1,
13764 combat_slots: Vec::new(),
13765 rotation_presets: Vec::new(),
13766 known_abilities: Vec::new(),
13767 hotbar: vec![None; 9],
13768 max_abilities_per_rotation: 0,
13769 show_loadout_menu: false,
13770 show_keychain_menu: false,
13771 keychain_menu_index: 0,
13772 show_rotation_editor: false,
13773 loadout_menu_index: 0,
13774 loadout_hotbar_slot: 1,
13775 loadout_ability_index: 0,
13776 loadout_focus_presets: false,
13777 rotation_editor: RotationEditorState::default(),
13778 harvest_in_progress: false,
13779 harvest_started_at: None,
13780 pending_craft_ack: None,
13781 pending_worker_job_ack: None,
13782 attending_worker_instance_id: None,
13783 quest_log: Vec::new(),
13784 interactables: Vec::new(),
13785 ledger: None,
13786 career: None,
13787 character_sheet_tab: CharacterSheetTab::Character,
13788 ledger_period: LedgerPeriod::Day,
13789 show_quest_offer: false,
13790 pending_quest_offer: None,
13791 show_quest_menu: false,
13792 quest_menu_index: 0,
13793 quest_withdraw_confirm: false,
13794 hired_workers: Vec::new(),
13795 show_workers_menu: false,
13796 workers_menu_index: 0,
13797 workers_menu_compact: false,
13798 worker_step_display: BTreeMap::new(),
13799 worker_error_display: BTreeMap::new(),
13800 show_worker_give_picker: false,
13801 worker_give_picker_index: 0,
13802 worker_give_picker: None,
13803 show_worker_give_target_picker: false,
13804 worker_give_target_picker_index: 0,
13805 worker_give_target_picker: None,
13806 show_worker_take_picker: false,
13807 worker_take_picker_index: 0,
13808 worker_take_picker: None,
13809 show_worker_teach_picker: false,
13810 worker_teach_picker_index: 0,
13811 worker_teach_picker: None,
13812 worker_route_editor: None,
13813 progression_curve: None,
13814 };
13815 state.player = state.entities.first().cloned();
13816 state
13817 }
13818
13819 #[test]
13820 fn whisper_cancels_when_peer_walks_out_of_range() {
13821 let mut state = sample_state();
13822 state.player = state.entities.first().cloned();
13823 let mut peer = state.entities[0].clone();
13824 peer.id = 2;
13825 peer.label = "Ada".into();
13826 peer.transform.position = WorldCoord::surface(129.0, 128.0); state.entities.push(peer.clone());
13828 state.social_chat.focus_whisper(2, "Ada");
13829 state.refresh_whisper_range();
13830 assert!(matches!(
13831 state.social_chat.thread,
13832 crate::social::ChatThreadKind::Whisper { peer: 2 }
13833 ));
13834
13835 peer.transform.position = WorldCoord::surface(132.0, 128.0); state.entities[1] = peer;
13837 state.refresh_whisper_range();
13838 assert_eq!(
13839 state.social_chat.thread,
13840 crate::social::ChatThreadKind::Nearby
13841 );
13842 assert!(!state.social_chat.input_focused);
13843 }
13844
13845 #[test]
13846 fn probe_use_world_hired_worker_manage() {
13847 let mut state = sample_state();
13848 state.hired_workers.push(flatland_protocol::HiredWorkerView {
13849 instance_id: "worker-1".into(),
13850 entity_id: 42,
13851 def_id: "worker_laborer".into(),
13852 label: "Sam".into(),
13853 x: 129.0,
13854 y: 128.0,
13855 z: 0.0,
13856 mode: flatland_protocol::WorkerModeView::JobLoop,
13857 state: flatland_protocol::WorkerStateView::Working,
13858 step_label: "cultivate".into(),
13859 vitals: flatland_protocol::WorkerVitalsSummary {
13860 health_pct: 100.0,
13861 stamina_pct: 100.0,
13862 },
13863 carry_pct: 0.0,
13864 last_error: None,
13865 wage_copper_per_interval: 1,
13866 effective_wage_copper: 1,
13867 wage_meters_walked: 0.0,
13868 lodging_container_id: None,
13869 route: None,
13870 route_stop_index: None,
13871 known_blueprint_ids: Vec::new(),
13872 level: 1,
13873 worker_xp: 0.0,
13874 inventory: Vec::new(),
13875 });
13876 let probe = state.probe_use_world();
13877 let primary = probe.primary.expect("primary");
13878 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
13879 assert_eq!(primary.id, "worker-1");
13880 assert!(primary.hint_line().contains("Manage"));
13881 assert!(primary.hint_line().contains("Sam"));
13882 assert_eq!(
13883 state.nearest_interact_target().as_deref(),
13884 Some("worker-1")
13885 );
13886 }
13887
13888 #[test]
13889 fn market_clerk_verb_options_include_market() {
13890 let mut state = sample_state();
13891 state.npcs.push(flatland_protocol::NpcView {
13892 id: "mira_market".into(),
13893 label: "Mira".into(),
13894 role: "market_clerk".into(),
13895 x: 129.0,
13896 y: 128.0,
13897 building_id: Some("town_market".into()),
13898 entity_id: None,
13899 life_state: None,
13900 hp_pct: None,
13901 can_trade: false,
13902 tile_id: None,
13903 behavior_state: None,
13904 presentation_state: None,
13905 sprite_mode: None,
13906 paperdoll_ref: None,
13907 });
13908 state.npc_verb_target = Some("mira_market".into());
13909 assert_eq!(state.npc_verb_options(), vec!["Market", "Talk"]);
13910 }
13911
13912 #[test]
13913 fn market_list_excludes_currency_stacks() {
13914 let mut state = sample_state();
13915 state.inventory_stacks = vec![
13916 flatland_protocol::ItemStack {
13917 template_id: "copper_coin".into(),
13918 quantity: 50,
13919 item_instance_id: Some(uuid::Uuid::from_u128(10)),
13920 display_name: Some("Copper Coin".into()),
13921 ..Default::default()
13922 },
13923 flatland_protocol::ItemStack {
13924 template_id: "oak_log".into(),
13925 quantity: 2,
13926 item_instance_id: Some(uuid::Uuid::from_u128(11)),
13927 display_name: Some("Oak Log".into()),
13928 ..Default::default()
13929 },
13930 flatland_protocol::ItemStack {
13931 template_id: "whisper_stone".into(),
13932 quantity: 1,
13933 item_instance_id: Some(uuid::Uuid::from_u128(12)),
13934 display_name: Some("Whisper Stone".into()),
13935 category: Some("quest".into()),
13936 listable: Some(false),
13937 ..Default::default()
13938 },
13939 ];
13940 let opts = state.market_list_item_options(&MarketListSourceKind::Person);
13941 assert_eq!(opts.len(), 1);
13942 assert!(opts[0].label.contains("Oak"));
13943 }
13944
13945 #[test]
13946 fn market_browse_filters_by_category_and_search() {
13947 let mut state = sample_state();
13948 state.market_panel = Some(flatland_protocol::MarketPanel {
13949 npc_id: "mira_market".into(),
13950 npc_label: "Mira".into(),
13951 building_id: "town_market".into(),
13952 building_label: "Town Market".into(),
13953 used_volume: 0.0,
13954 max_volume: 100.0,
13955 listings: vec![
13956 flatland_protocol::MarketListingView {
13957 listing_id: uuid::Uuid::from_u128(1),
13958 seller_character_id: uuid::Uuid::from_u128(2),
13959 seller_label: "Ada".into(),
13960 hall_building_id: "town_market".into(),
13961 hall_label: "Town Market".into(),
13962 template_id: "oak_log".into(),
13963 display_name: "Oak Log".into(),
13964 category: "resource".into(),
13965 quantity: 3,
13966 unit_price_copper: 10,
13967 line_total_copper: 30,
13968 mine: false,
13969 },
13970 flatland_protocol::MarketListingView {
13971 listing_id: uuid::Uuid::from_u128(3),
13972 seller_character_id: uuid::Uuid::from_u128(2),
13973 seller_label: "Ada".into(),
13974 hall_building_id: "town_market".into(),
13975 hall_label: "Town Market".into(),
13976 template_id: "short_sword".into(),
13977 display_name: "Short Sword".into(),
13978 category: "weapon".into(),
13979 quantity: 1,
13980 unit_price_copper: 100,
13981 line_total_copper: 100,
13982 mine: false,
13983 },
13984 ],
13985 tax_bps: 0,
13986 tax_flat_copper: 0,
13987 list_vaults: vec![],
13988 });
13989 assert_eq!(state.market_filtered_listing_indices().len(), 2);
13990 state.market_category_filter = Some("Weapons");
13991 let weapons = state.market_filtered_listing_indices();
13992 assert_eq!(weapons.len(), 1);
13993 assert_eq!(
13994 state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
13995 "Short Sword"
13996 );
13997 state.market_category_filter = None;
13998 state.market_filter = "oak".into();
13999 let oak = state.market_filtered_listing_indices();
14000 assert_eq!(oak.len(), 1);
14001 assert_eq!(
14002 state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
14003 "Oak Log"
14004 );
14005 }
14006
14007 #[test]
14008 fn market_list_source_includes_person_and_vaults() {
14009 let mut state = sample_state();
14010 let item_id = uuid::Uuid::from_u128(1);
14011 state.inventory_stacks = vec![flatland_protocol::ItemStack {
14012 template_id: "oak_log".into(),
14013 quantity: 2,
14014 item_instance_id: Some(item_id),
14015 display_name: Some("Oak Log".into()),
14016 ..Default::default()
14017 }];
14018 state.market_panel = Some(flatland_protocol::MarketPanel {
14019 npc_id: "mira_market".into(),
14020 npc_label: "Mira".into(),
14021 building_id: "town_market".into(),
14022 building_label: "Town Market".into(),
14023 used_volume: 0.0,
14024 max_volume: 100.0,
14025 listings: vec![],
14026 tax_bps: 0,
14027 tax_flat_copper: 0,
14028 list_vaults: vec![flatland_protocol::MarketListVault {
14029 building_id: "town_storage".into(),
14030 building_label: "Town Storage".into(),
14031 contents: vec![flatland_protocol::ItemStack {
14032 template_id: "lumber".into(),
14033 quantity: 1,
14034 item_instance_id: Some(uuid::Uuid::from_u128(2)),
14035 display_name: Some("Lumber".into()),
14036 ..Default::default()
14037 }],
14038 }],
14039 });
14040 let sources = state.market_list_source_options();
14041 assert_eq!(sources.len(), 2);
14042 assert!(matches!(sources[0].0, MarketListSourceKind::Person));
14043 assert!(matches!(
14044 sources[1].0,
14045 MarketListSourceKind::TownStorage { .. }
14046 ));
14047 assert!(sources[1].1.contains("Town Storage"));
14048 }
14049
14050 #[test]
14051 fn probe_use_world_npc_beats_nearby_loot() {
14052 let mut state = sample_state();
14053 state.npcs.push(flatland_protocol::NpcView {
14054 id: "ada".into(),
14055 label: "Ada".into(),
14056 role: "broker".into(),
14057 x: 129.0,
14058 y: 128.0,
14059 building_id: None,
14060 entity_id: None,
14061 life_state: None,
14062 hp_pct: None,
14063 can_trade: true,
14064 tile_id: None,
14065 behavior_state: None,
14066 presentation_state: None,
14067 sprite_mode: None,
14068 paperdoll_ref: None,
14069 });
14070 state.ground_drops.push(flatland_protocol::GroundDropView {
14071 id: "d1".into(),
14072 template_id: "lumber".into(),
14073 quantity: 1,
14074 x: 128.5,
14075 y: 128.0,
14076 z: 0.0,
14077 tile_id: None,
14078 display_name: None,
14079 yaw: 0.0,
14080 pitch: 0.0,
14081 roll: 0.0,
14082 draw_scale: 1.0,
14083 });
14084 let probe = state.probe_use_world();
14085 let primary = probe.primary.expect("primary");
14086 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
14087 assert_eq!(primary.id, "ada");
14088 }
14089
14090 #[test]
14091 fn probe_use_world_harvest_when_in_range() {
14092 let state = sample_state(); let probe = state.probe_use_world();
14094 assert!(
14095 probe.primary.is_none(),
14096 "oak is 2m away, out of harvest range"
14097 );
14098 assert!(probe
14099 .candidates
14100 .iter()
14101 .any(|c| c.kind == crate::UseWorldKind::Harvest));
14102
14103 let mut state = sample_state();
14104 state.resource_nodes[0].x = 129.0;
14105 let probe = state.probe_use_world();
14106 let primary = probe.primary.expect("primary");
14107 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
14108 }
14109
14110 #[test]
14111 fn probe_use_world_door_uses_building_label() {
14112 let mut state = sample_state();
14113 state.doors[0].x = 129.0;
14114 state.doors[0].y = 128.0;
14115 let probe = state.probe_use_world();
14116 let primary = probe.primary.expect("primary");
14117 assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
14118 assert_eq!(primary.label, "Broker");
14119 assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
14120 }
14121
14122 #[test]
14123 fn empty_entity_tick_preserves_welcome_snapshot() {
14124 let mut state = sample_state();
14125 state.inventory.insert("carrot".into(), 3);
14126 let delta = TickDelta {
14127 tick: 1,
14128 entities: vec![],
14129 resource_nodes: vec![],
14130 ground_drops: vec![],
14131 placed_containers: vec![],
14132 buildings: vec![],
14133 doors: vec![],
14134 interior_map: None,
14135 npcs: vec![],
14136 inventory: vec![],
14137 blueprints: vec![],
14138 world_clock: flatland_protocol::WorldClock::default(),
14139 combat: None,
14140 quest_log: vec![],
14141 hired_workers: Vec::new(),
14142 interactables: vec![],
14143 ledger: None,
14144 career: None,
14145 combat_fx: Vec::new(),
14146 property_plots: Vec::new(),
14147 terrain_overlays: Vec::new(),
14148 };
14149
14150 state.apply_tick_fields(&delta, 1);
14151
14152 assert_eq!(state.entities.len(), 1);
14153 assert!(state.player.is_some());
14154 assert_eq!(state.inventory.get("carrot"), Some(&3));
14155 assert_eq!(state.resource_nodes.len(), 1);
14156 }
14157
14158 #[test]
14159 fn tick_preserves_world_layers_when_delta_omits_them() {
14160 let mut state = sample_state();
14161 let delta = TickDelta {
14162 tick: 1,
14163 entities: state.entities.clone(),
14164 resource_nodes: vec![],
14165 ground_drops: vec![],
14166 placed_containers: vec![],
14167 buildings: vec![],
14168 doors: vec![],
14169 interior_map: None,
14170 npcs: vec![],
14171 inventory: vec![],
14172 blueprints: vec![],
14173 world_clock: flatland_protocol::WorldClock::default(),
14174 combat: None,
14175 quest_log: vec![],
14176 hired_workers: Vec::new(),
14177 interactables: vec![],
14178 ledger: None,
14179 career: None,
14180 combat_fx: Vec::new(),
14181 property_plots: Vec::new(),
14182 terrain_overlays: Vec::new(),
14183 };
14184
14185 state.apply_tick_fields(&delta, 1);
14186
14187 assert_eq!(state.resource_nodes.len(), 1);
14188 assert_eq!(state.buildings.len(), 1);
14189 assert_eq!(state.doors.len(), 1);
14190 }
14191
14192 #[test]
14193 fn tick_updates_resource_nodes_when_server_sends_them() {
14194 let mut state = sample_state();
14195 let delta = TickDelta {
14196 tick: 1,
14197 entities: state.entities.clone(),
14198 resource_nodes: vec![ResourceNodeView {
14199 id: "oak-1".into(),
14200 label: "Oak".into(),
14201 x: 130.0,
14202 y: 128.0,
14203 z: 0.0,
14204 item_template: "oak_log".into(),
14205 state: ResourceNodeState::Cooldown,
14206 blocking: true,
14207 blocking_radius_m: 0.8,
14208 tile_id: None,
14209 yaw: 0.0,
14210 pitch: 0.0,
14211 roll: 0.0,
14212 draw_scale: 1.0,
14213 sprite_mode: None,
14214 growth_progress: None,
14215 presentation_state: None,
14216 channel_start_tick: None,
14217 channel_end_tick: None,
14218 harvest_drop_templates: vec![],
14219 }],
14220 buildings: vec![],
14221 doors: vec![],
14222 interior_map: None,
14223 npcs: vec![],
14224 inventory: vec![],
14225 blueprints: vec![],
14226 world_clock: flatland_protocol::WorldClock::default(),
14227 ground_drops: vec![],
14228 placed_containers: vec![],
14229 combat: None,
14230 quest_log: vec![],
14231 hired_workers: Vec::new(),
14232 interactables: vec![],
14233 ledger: None,
14234 career: None,
14235 combat_fx: Vec::new(),
14236 property_plots: Vec::new(),
14237 terrain_overlays: Vec::new(),
14238 };
14239
14240 state.apply_tick_fields(&delta, 1);
14241
14242 assert!(matches!(
14243 state.resource_nodes[0].state,
14244 ResourceNodeState::Cooldown
14245 ));
14246 }
14247
14248 #[test]
14249 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
14250 let mut state = GameState {
14251 session_id: 1,
14252 entity_id: 1,
14253 character_id: None,
14254 tick: 0,
14255 chunk_rev: 0,
14256 content_rev: 0,
14257 publish_rev: 0,
14258 entities: vec![EntityState {
14259 id: 1,
14260 label: "You".into(),
14261 transform: Transform {
14262 position: WorldCoord::surface(4.5, 2.0),
14263 yaw: 0.0,
14264 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
14265 },
14266 vitals: None,
14267 attributes: None,
14268 skills: None,
14269 inside_building: Some("broker_hut".into()),
14270 tile_id: None,
14271 paperdoll_ref: None,
14272 presentation_state: None,
14273 sprite_mode: None,
14274 progression_xp: None,
14275 combat_cues: vec![],
14276 }],
14277 player: None,
14278 resource_nodes: vec![],
14279 ground_drops: vec![],
14280 placed_containers: vec![],
14281 buildings: vec![BuildingView {
14282 id: "broker_hut".into(),
14283 label: "Broker".into(),
14284 x: 158.0,
14285 y: 124.0,
14286 width_m: 8.0,
14287 depth_m: 6.0,
14288 interior_blueprint: Some("broker_hut".into()),
14289 tags: vec![],
14290 market_boundary_zone_ids: vec![],
14291 market_max_volume: None,
14292 wall_set: None,
14293 roof_set: None,
14294 }],
14295 doors: vec![flatland_protocol::DoorView {
14296 id: "broker_hut_exit".into(),
14297 building_id: "broker_hut".into(),
14298 x: 4.3,
14299 y: 0.9,
14300 open: true,
14301 portal: Some("front".into()),
14302 }],
14303 interior_map: None,
14304 npcs: vec![flatland_protocol::NpcView {
14305 id: "ada_broker".into(),
14306 label: "Ada".into(),
14307 x: 4.5,
14308 y: 2.0,
14309 building_id: Some("broker_hut".into()),
14310 role: "broker".into(),
14311 entity_id: None,
14312 life_state: None,
14313 hp_pct: None,
14314 can_trade: true,
14315 tile_id: None,
14316 behavior_state: None,
14317 presentation_state: None,
14318 sprite_mode: None,
14319 paperdoll_ref: None,
14320 }],
14321 blueprints: vec![],
14322 world_x0: 0.0,
14323 world_y0: 0.0,
14324 world_width_m: 256.0,
14325 world_height_m: 256.0,
14326 terrain_zones: Vec::new(),
14327 z_platforms: Vec::new(),
14328 z_transitions: Vec::new(),
14329 world_clock: flatland_protocol::WorldClock::default(),
14330 inventory: std::collections::HashMap::new(),
14331 inventory_hints: std::collections::HashMap::new(),
14332 logs: VecDeque::new(),
14333 intents_sent: 0,
14334 ticks_received: 0,
14335 connected: true,
14336 disconnect_reason: None,
14337 show_stats: false,
14338 hud_log_hidden: false,
14339 show_equip_menu: false,
14340 equip_menu_index: 0,
14341 show_craft_menu: false,
14342 craft_menu_index: 0,
14343 craft_batch_quantity: 1,
14344 show_shop_menu: false,
14345 shop_catalog: None,
14346 bank_panel: None,
14347 bank_menu_index: 0,
14348 bank_ui_mode: BankUiMode::Menu,
14349 storage_panel: None,
14350 market_panel: None,
14351 market_menu_index: 0,
14352 market_filter: String::new(),
14353 market_filter_focused: false,
14354 market_category_filter: None,
14355 market_buy_confirm: None,
14356 market_ui_mode: MarketUiMode::Browse,
14357 storage_menu_index: 0,
14358 storage_ui_mode: StorageUiMode::Menu,
14359 shop_tab: ShopTab::default(),
14360 shop_menu_index: 0,
14361 shop_quantity: 1,
14362 shop_trade_log: VecDeque::new(),
14363 show_npc_verb_menu: false,
14364 npc_verb_target: None,
14365 npc_verb_index: 0,
14366 player_verbs: crate::social::PlayerVerbState::default(),
14367 social_chat: crate::social::SocialChatState::default(),
14368 trade_ui: crate::social::TradeUiState::default(),
14369 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
14370 show_npc_chat: false,
14371 npc_chat: None,
14372 show_inventory_menu: false,
14373 inventory_menu_index: 0,
14374 inventory_tab: InventoryTab::OnPerson,
14375 inventory_filter: String::new(),
14376 inventory_filter_focused: false,
14377 show_move_picker: false,
14378 show_rename_prompt: false,
14379 show_worker_rename: false,
14380 rename_buffer: String::new(),
14381 move_picker_index: 0,
14382 move_picker: None,
14383 show_grant_picker: false,
14384 grant_picker_index: 0,
14385 grant_picker: None,
14386 show_destroy_picker: false,
14387 destroy_confirm_pending: false,
14388 destroy_picker: None,
14389 combat_target: None,
14390 combat_target_label: None,
14391 combat_fx: Vec::new(),
14392 property_zones: Vec::new(),
14393 tax_zones: Vec::new(),
14394 growth_zones: Vec::new(),
14395 biome_zones: Vec::new(),
14396 property_plots: Vec::new(),
14397 property_plot_settings: None,
14398 claim_mode: None,
14399 relocate_mode: None,
14400 sell_plot_confirm: None,
14401 sell_plot_armed_at: None,
14402 show_plant_menu: false,
14403 plant_menu_index: 0,
14404 show_farm_access: false,
14405 farm_access_name_draft: String::new(),
14406 farm_access_discount_bps: 0,
14407 farm_access_index: 0,
14408 plant_quantity: 1,
14409 in_combat: false,
14410 auto_attack: true,
14411 combat_has_los: false,
14412 attack_cd_ticks: 0,
14413 gcd_ticks: 0,
14414 weapon_ability_id: "unarmed".into(),
14415 mainhand_template_id: None,
14416 mainhand_label: None,
14417 offhand_template_id: None,
14418 offhand_label: None,
14419 mainhand_hand_slots: 1,
14420 defense: None,
14421 worn: BTreeMap::new(),
14422 carry_mass: 0.0,
14423 carry_mass_max: 0.0,
14424 encumbrance: flatland_protocol::EncumbranceState::Light,
14425 inventory_stacks: Vec::new(),
14426 keychain_stacks: Vec::new(),
14427 whisper_pouch_stacks: Vec::new(),
14428 combat_target_detail: None,
14429 statuses: Vec::new(),
14430 cast_progress: None,
14431 timed_channel: None,
14432 ability_cooldowns: Vec::new(),
14433 blocking_active: false,
14434 max_target_slots: 1,
14435 combat_slots: Vec::new(),
14436 rotation_presets: Vec::new(),
14437 known_abilities: Vec::new(),
14438 hotbar: vec![None; 9],
14439 max_abilities_per_rotation: 0,
14440 show_loadout_menu: false,
14441 show_keychain_menu: false,
14442 keychain_menu_index: 0,
14443 show_rotation_editor: false,
14444 loadout_menu_index: 0,
14445 loadout_hotbar_slot: 1,
14446 loadout_ability_index: 0,
14447 loadout_focus_presets: false,
14448 rotation_editor: RotationEditorState::default(),
14449 harvest_in_progress: false,
14450 harvest_started_at: None,
14451 pending_craft_ack: None,
14452 pending_worker_job_ack: None,
14453 attending_worker_instance_id: None,
14454 quest_log: Vec::new(),
14455 interactables: Vec::new(),
14456 ledger: None,
14457 career: None,
14458 character_sheet_tab: CharacterSheetTab::Character,
14459 ledger_period: LedgerPeriod::Day,
14460 show_quest_offer: false,
14461 pending_quest_offer: None,
14462 show_quest_menu: false,
14463 quest_menu_index: 0,
14464 quest_withdraw_confirm: false,
14465 hired_workers: Vec::new(),
14466 show_workers_menu: false,
14467 workers_menu_index: 0,
14468 workers_menu_compact: false,
14469 worker_step_display: BTreeMap::new(),
14470 worker_error_display: BTreeMap::new(),
14471 show_worker_give_picker: false,
14472 worker_give_picker_index: 0,
14473 worker_give_picker: None,
14474 show_worker_give_target_picker: false,
14475 worker_give_target_picker_index: 0,
14476 worker_give_target_picker: None,
14477 show_worker_take_picker: false,
14478 worker_take_picker_index: 0,
14479 worker_take_picker: None,
14480 show_worker_teach_picker: false,
14481 worker_teach_picker_index: 0,
14482 worker_teach_picker: None,
14483 worker_route_editor: None,
14484 progression_curve: None,
14485 };
14486 state.player = state.entities.first().cloned();
14487 assert_eq!(
14488 state.nearest_interact_target().as_deref(),
14489 Some("ada_broker")
14490 );
14491 }
14492
14493 #[test]
14494 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
14495 let mut state = sample_state();
14496 state.placed_containers = vec![
14499 flatland_protocol::PlacedContainerView {
14500 id: "near".into(),
14501 template_id: "wooden_chest_small".into(),
14502 display_name: "Wooden Chest".into(),
14503 x: 130.0,
14504 y: 128.0,
14505 z: 0.0,
14506 locked: true,
14507 accessible: true,
14508 owner_character_id: None,
14509 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
14510 lock_id: None,
14511 capacity_volume: None,
14512 item_instance_id: Some(uuid::Uuid::from_u128(1)),
14513 tile_id: None,
14514 worker_lodging_capacity: None,
14515 blocking: false,
14516 blocking_radius_m: 0.0,
14517 },
14518 flatland_protocol::PlacedContainerView {
14519 id: "far".into(),
14520 template_id: "wooden_chest_small".into(),
14521 display_name: "Distant Chest".into(),
14522 x: 128.0 + CONTAINER_RANGE_M + 5.0,
14523 y: 128.0,
14524 z: 0.0,
14525 locked: false,
14526 accessible: true,
14527 owner_character_id: None,
14528 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
14529 lock_id: None,
14530 capacity_volume: None,
14531 item_instance_id: Some(uuid::Uuid::from_u128(2)),
14532 tile_id: None,
14533 worker_lodging_capacity: None,
14534 blocking: false,
14535 blocking_radius_m: 0.0,
14536 },
14537 ];
14538
14539 let nearby = state.nearby_containers();
14540 assert_eq!(
14541 nearby.len(),
14542 1,
14543 "far chest must not appear once out of range"
14544 );
14545 assert_eq!(nearby[0].view.id, "near");
14546 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
14547 assert!(nearby[0].rows[0].is_chest_shell);
14548
14549 state.placed_containers[0].accessible = false;
14552 let nearby = state.nearby_containers();
14553 assert_eq!(nearby.len(), 1);
14554 assert_eq!(nearby[0].rows.len(), 1);
14555 assert!(nearby[0].rows[0].is_chest_shell);
14556 }
14557
14558 #[test]
14559 fn chest_pickup_destinations_offer_person_and_worn_bag() {
14560 let mut state = sample_state();
14561 let back_id = uuid::Uuid::from_u128(42);
14562 state.worn.insert(
14563 BodySlot::Back,
14564 flatland_protocol::ItemStack {
14565 template_id: "travel_backpack".into(),
14566 quantity: 1,
14567 item_instance_id: Some(back_id),
14568 props: Default::default(),
14569 status_bindings: Vec::new(),
14570 contents: Vec::new(),
14571 display_name: Some("Travel Backpack".into()),
14572 category: Some("container".into()),
14573 base_mass: Some(2.5),
14574 base_volume: Some(12.0),
14575 capacity_volume: Some(80.0),
14576 stackable: Some(false),
14577 world_placeable: Some(false),
14578 worker_lodging_capacity: None,
14579 equip_slot: None,
14580 armor_physical: None,
14581 resists: vec![],
14582 hand_slots: None,
14583 listable: None,
14584 },
14585 );
14586 let opts = state.chest_pickup_destinations("chest-1");
14587 assert!(matches!(
14588 opts.first().map(|o| &o.kind),
14589 Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
14590 ));
14591 assert!(opts.iter().any(|o| matches!(
14592 &o.kind,
14593 MoveOptionKind::PickupPlaced {
14594 nest_parent_instance_id: None,
14595 ..
14596 }
14597 )));
14598 assert!(opts.iter().any(|o| matches!(
14599 &o.kind,
14600 MoveOptionKind::PickupPlaced {
14601 nest_parent_instance_id: Some(id),
14602 ..
14603 } if *id == back_id
14604 )));
14605 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
14606 }
14607
14608 #[test]
14609 fn placed_container_public_label_hides_owner_custom_name() {
14610 let owner = uuid::Uuid::from_u128(99);
14611 let mut state = sample_state();
14612 state.character_id = Some(uuid::Uuid::from_u128(1));
14613 state.inventory_hints.insert(
14614 "wooden_chest_medium".into(),
14615 InventoryHint {
14616 display_name: "Medium Wooden Chest".into(),
14617 category: "container".into(),
14618 base_mass: None,
14619 base_volume: None,
14620 capacity_volume: None,
14621 stackable: false,
14622 listable: true,
14623 },
14624 );
14625 let chest = flatland_protocol::PlacedContainerView {
14626 id: "c1".into(),
14627 template_id: "wooden_chest_medium".into(),
14628 display_name: "Barry's Loot #a3f2".into(),
14629 x: 128.0,
14630 y: 128.0,
14631 z: 0.0,
14632 locked: false,
14633 accessible: true,
14634 owner_character_id: Some(owner),
14635 contents: vec![],
14636 lock_id: None,
14637 capacity_volume: None,
14638 item_instance_id: None,
14639 tile_id: None,
14640 worker_lodging_capacity: None,
14641 blocking: false,
14642 blocking_radius_m: 0.0,
14643 };
14644 assert_eq!(
14645 state.placed_container_public_label(&chest),
14646 "Medium Wooden Chest"
14647 );
14648 state.character_id = Some(owner);
14649 assert_eq!(
14650 state.placed_container_public_label(&chest),
14651 "Barry's Loot #a3f2"
14652 );
14653 }
14654
14655 #[test]
14656 fn location_context_shows_crop_growth_percent_not_depleted() {
14657 let mut state = sample_state();
14658 state.player = state.entities.first().cloned();
14659 state.resource_nodes[0].label = "Carrot (growing)".into();
14660 state.resource_nodes[0].x = 128.2;
14661 state.resource_nodes[0].y = 128.0;
14662 state.resource_nodes[0].state = ResourceNodeState::Cooldown;
14663 state.resource_nodes[0].growth_progress = Some(0.47);
14664 let lines = state.location_context_lines();
14665 let line = lines
14666 .iter()
14667 .find(|l| l.text.contains("Carrot"))
14668 .map(|l| l.text.as_str())
14669 .unwrap_or("");
14670 assert!(
14671 line.contains("(growing, 47%)"),
14672 "expected growth percent, got: {line}"
14673 );
14674 assert!(
14675 !line.contains("depleted"),
14676 "growing crop should not show depleted: {line}"
14677 );
14678 }
14679
14680 #[test]
14681 fn resource_node_near_action_suffix_prefers_growth() {
14682 let node = ResourceNodeView {
14683 id: "crop".into(),
14684 label: "Wheat".into(),
14685 x: 0.0,
14686 y: 0.0,
14687 z: 0.0,
14688 item_template: "wheat".into(),
14689 state: ResourceNodeState::Cooldown,
14690 blocking: false,
14691 blocking_radius_m: 0.0,
14692 tile_id: None,
14693 yaw: 0.0,
14694 pitch: 0.0,
14695 roll: 0.0,
14696 draw_scale: 1.0,
14697 sprite_mode: None,
14698 growth_progress: Some(0.12),
14699 presentation_state: None,
14700 channel_start_tick: None,
14701 channel_end_tick: None,
14702 harvest_drop_templates: vec![],
14703 };
14704 assert_eq!(
14705 resource_node_near_action_suffix(&node),
14706 " (growing, 12%)"
14707 );
14708 }
14709
14710 #[test]
14711 fn location_context_lists_nearby_resource_node() {
14712 let mut state = sample_state();
14713 state.player = state.entities.first().cloned();
14714 state.resource_nodes[0].x = 128.2;
14715 state.resource_nodes[0].y = 128.0;
14716 let lines = state.location_context_lines();
14717 assert!(
14718 lines
14719 .iter()
14720 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
14721 "expected resource node in context: {:?}",
14722 lines
14723 );
14724 }
14725
14726 #[test]
14727 fn quest_board_usable_within_board_radius() {
14728 let mut state = sample_state();
14729 state.player = state.entities.first().cloned();
14730 state.interactables = vec![flatland_protocol::InteractableView {
14731 id: "board-1".into(),
14732 kind: "quest_board".into(),
14733 label: "Town Quest Board".into(),
14734 x: 130.5,
14735 y: 128.0,
14736 z: 0.0,
14737 board_id: Some("starter_town_board".into()),
14738 }];
14739 assert_eq!(
14741 state.nearest_interact_target().as_deref(),
14742 Some("board-1"),
14743 "quest board should be selectable at ~2.5m"
14744 );
14745 let lines = state.location_context_lines();
14746 assert!(
14747 lines
14748 .iter()
14749 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
14750 "HUD should advertise f when board is in range: {:?}",
14751 lines
14752 );
14753 }
14754
14755 #[test]
14756 fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
14757 let mut state = sample_state();
14758 state.worn.insert(
14759 BodySlot::Back,
14760 flatland_protocol::ItemStack {
14761 template_id: "travel_backpack".into(),
14762 quantity: 1,
14763 item_instance_id: Some(uuid::Uuid::from_u128(3)),
14764 props: Default::default(),
14765 status_bindings: Vec::new(),
14766 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
14767 display_name: None,
14768 category: None,
14769 base_mass: None,
14770 base_volume: None,
14771 capacity_volume: None,
14772 stackable: None,
14773 world_placeable: None,
14774 worker_lodging_capacity: None,
14775 equip_slot: None,
14776 armor_physical: None,
14777 resists: vec![],
14778 hand_slots: None,
14779 listable: None,
14780 },
14781 );
14782 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
14783 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
14784 id: "chest-1".into(),
14785 template_id: "wooden_chest_small".into(),
14786 display_name: "Wooden Chest".into(),
14787 x: 129.0,
14788 y: 128.0,
14789 z: 0.0,
14790 locked: false,
14791 accessible: true,
14792 owner_character_id: None,
14793 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
14794 lock_id: None,
14795 capacity_volume: None,
14796 item_instance_id: Some(uuid::Uuid::from_u128(4)),
14797 tile_id: None,
14798 worker_lodging_capacity: None,
14799 blocking: false,
14800 blocking_radius_m: 0.0,
14801 }];
14802
14803 state.inventory_tab = InventoryTab::OnPerson;
14804 let rows = state.inventory_selectable_rows();
14805 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
14806 assert_eq!(
14807 sections,
14808 vec![
14809 InventorySection::Worn, InventorySection::Worn, InventorySection::Person, ]
14813 );
14814 assert_eq!(rows[0].stack.template_id, "travel_backpack");
14815 assert!(rows[0].is_equip_shell);
14816 assert_eq!(rows[1].stack.template_id, "iron_ore");
14817 assert_eq!(rows[1].depth, 1);
14818 assert_eq!(rows[2].stack.template_id, "lumber");
14819
14820 let lines = state.inventory_browser_lines();
14821 assert!(lines.iter().any(|l| matches!(
14822 l,
14823 InventoryBrowserLine::Section(s) if s.contains("Worn")
14824 )));
14825 assert!(lines.iter().any(|l| matches!(
14826 l,
14827 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
14828 || text.contains("backpack")
14829 )));
14830 assert!(!lines.iter().any(|l| matches!(
14831 l,
14832 InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
14833 )));
14834
14835 state.inventory_tab = InventoryTab::Nearby;
14836 let nearby_rows = state.inventory_selectable_rows();
14837 assert_eq!(nearby_rows.len(), 2);
14838 assert!(nearby_rows[0].is_chest_shell);
14839 assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
14840 let nearby_lines = state.inventory_browser_lines();
14841 assert!(nearby_lines.iter().any(|l| matches!(
14842 l,
14843 InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
14844 )));
14845 }
14846
14847 #[test]
14848 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
14849 let mut state = sample_state();
14850 let back_id = uuid::Uuid::from_u128(5);
14851 state.worn.insert(
14852 BodySlot::Back,
14853 flatland_protocol::ItemStack {
14854 template_id: "travel_backpack".into(),
14855 quantity: 1,
14856 item_instance_id: Some(back_id),
14857 props: Default::default(),
14858 status_bindings: Vec::new(),
14859 contents: Vec::new(),
14860 display_name: None,
14861 category: Some("container".into()),
14862 base_mass: None,
14863 base_volume: None,
14864 capacity_volume: Some(80.0),
14865 stackable: None,
14866 world_placeable: None,
14867 worker_lodging_capacity: None,
14868 equip_slot: None,
14869 armor_physical: None,
14870 resists: vec![],
14871 hand_slots: None,
14872 listable: None,
14873 },
14874 );
14875 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
14876 id: "chest-1".into(),
14877 template_id: "wooden_chest_small".into(),
14878 display_name: "Wooden Chest".into(),
14879 x: 129.0,
14880 y: 128.0,
14881 z: 0.0,
14882 locked: false,
14883 accessible: true,
14884 owner_character_id: None,
14885 contents: Vec::new(),
14886 lock_id: None,
14887 capacity_volume: None,
14888 item_instance_id: Some(uuid::Uuid::from_u128(6)),
14889 tile_id: None,
14890 worker_lodging_capacity: None,
14891 blocking: false,
14892 blocking_radius_m: 0.0,
14893 }];
14894
14895 let opts = state.move_destinations_for(
14898 &flatland_protocol::InventoryLocation::Root,
14899 None,
14900 None,
14901 "lumber",
14902 );
14903 assert!(!opts.iter().any(|o| matches!(
14904 &o.kind,
14905 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
14906 )));
14907 assert!(opts.iter().any(|o| matches!(
14908 &o.kind,
14909 MoveOptionKind::Move { location, parent_instance_id, .. }
14910 if *location == flatland_protocol::InventoryLocation::Worn {
14911 slot: BodySlot::Back,
14912 } && *parent_instance_id == Some(back_id)
14913 )));
14914 assert!(opts.iter().any(|o| matches!(
14915 &o.kind,
14916 MoveOptionKind::Move { location, .. }
14917 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
14918 )));
14919 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
14920 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
14921
14922 let from_backpack = flatland_protocol::InventoryLocation::Worn {
14926 slot: BodySlot::Back,
14927 };
14928 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
14929 assert!(!opts.iter().any(|o| matches!(
14930 &o.kind,
14931 MoveOptionKind::Move { location, parent_instance_id, .. }
14932 if *location == from_backpack && *parent_instance_id == Some(back_id)
14933 )));
14934 assert!(opts.iter().any(|o| matches!(
14935 &o.kind,
14936 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
14937 )));
14938 }
14939
14940 #[test]
14941 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
14942 let mut state = sample_state();
14943 state.worn.insert(
14946 BodySlot::Waist,
14947 flatland_protocol::ItemStack {
14948 template_id: "simple_belt".into(),
14949 quantity: 1,
14950 item_instance_id: Some(uuid::Uuid::from_u128(10)),
14951 props: Default::default(),
14952 status_bindings: Vec::new(),
14953 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
14954 display_name: None,
14955 category: Some("container".into()),
14956 base_mass: None,
14957 base_volume: None,
14958 capacity_volume: None,
14959 stackable: None,
14960 world_placeable: None,
14961 worker_lodging_capacity: None,
14962 equip_slot: None,
14963 armor_physical: None,
14964 resists: vec![],
14965 hand_slots: None,
14966 listable: None,
14967 },
14968 );
14969 state.worn.insert(
14970 BodySlot::Head,
14971 flatland_protocol::ItemStack {
14972 template_id: "cloth_cap".into(),
14973 quantity: 1,
14974 item_instance_id: Some(uuid::Uuid::from_u128(11)),
14975 props: Default::default(),
14976 status_bindings: Vec::new(),
14977 contents: Vec::new(),
14978 display_name: None,
14979 category: Some("armor".into()),
14980 base_mass: None,
14981 base_volume: None,
14982 capacity_volume: None,
14983 stackable: None,
14984 world_placeable: None,
14985 worker_lodging_capacity: None,
14986 equip_slot: None,
14987 armor_physical: None,
14988 resists: vec![],
14989 hand_slots: None,
14990 listable: None,
14991 },
14992 );
14993 state.worn.insert(
14994 BodySlot::Back,
14995 flatland_protocol::ItemStack {
14996 template_id: "travel_backpack".into(),
14997 quantity: 1,
14998 item_instance_id: Some(uuid::Uuid::from_u128(12)),
14999 props: Default::default(),
15000 status_bindings: Vec::new(),
15001 contents: Vec::new(),
15002 display_name: None,
15003 category: Some("container".into()),
15004 base_mass: None,
15005 base_volume: None,
15006 capacity_volume: None,
15007 stackable: None,
15008 world_placeable: None,
15009 worker_lodging_capacity: None,
15010 equip_slot: None,
15011 armor_physical: None,
15012 resists: vec![],
15013 hand_slots: None,
15014 listable: None,
15015 },
15016 );
15017
15018 let rows = state.worn_rows();
15019 assert_eq!(rows.len(), 4);
15021 assert_eq!(rows[0].stack.template_id, "cloth_cap");
15022 assert!(rows[0].is_equip_shell);
15023 assert_eq!(rows[1].stack.template_id, "travel_backpack");
15024 assert!(rows[1].is_equip_shell);
15025 assert_eq!(rows[2].stack.template_id, "simple_belt");
15026 assert!(rows[2].is_equip_shell);
15027 assert_eq!(rows[3].stack.template_id, "leather_pouch");
15028 assert_eq!(rows[3].depth, 1);
15029 assert!(!rows[3].is_equip_shell);
15030 }
15031
15032 #[test]
15033 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
15034 let mut state = sample_state();
15035 state.worn.insert(
15036 BodySlot::Waist,
15037 flatland_protocol::ItemStack {
15038 template_id: "simple_belt".into(),
15039 quantity: 1,
15040 item_instance_id: Some(uuid::Uuid::from_u128(20)),
15041 props: Default::default(),
15042 status_bindings: Vec::new(),
15043 contents: Vec::new(),
15044 display_name: Some("Simple Belt".into()),
15045 category: Some("container".into()),
15046 base_mass: None,
15047 base_volume: None,
15048 capacity_volume: None,
15049 stackable: None,
15050 world_placeable: None,
15051 worker_lodging_capacity: None,
15052 equip_slot: None,
15053 armor_physical: None,
15054 resists: vec![],
15055 hand_slots: None,
15056 listable: None,
15057 },
15058 );
15059 state.worn.insert(
15060 BodySlot::Head,
15061 flatland_protocol::ItemStack {
15062 template_id: "cloth_cap".into(),
15063 quantity: 1,
15064 item_instance_id: Some(uuid::Uuid::from_u128(21)),
15065 props: Default::default(),
15066 status_bindings: Vec::new(),
15067 contents: Vec::new(),
15068 display_name: Some("Cloth Cap".into()),
15069 category: Some("armor".into()),
15070 base_mass: None,
15071 base_volume: None,
15072 capacity_volume: None,
15073 stackable: None,
15074 world_placeable: None,
15075 worker_lodging_capacity: None,
15076 equip_slot: None,
15077 armor_physical: None,
15078 resists: vec![],
15079 hand_slots: None,
15080 listable: None,
15081 },
15082 );
15083
15084 let opts = state.move_destinations_for(
15085 &flatland_protocol::InventoryLocation::Root,
15086 None,
15087 None,
15088 "leather_pouch",
15089 );
15090 assert!(
15091 opts.iter().any(|o| matches!(
15092 &o.kind,
15093 MoveOptionKind::Move { location, .. }
15094 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
15095 )),
15096 "belt loop must be offered when moving a pouch"
15097 );
15098 assert!(
15099 !opts.iter().any(|o| matches!(
15100 &o.kind,
15101 MoveOptionKind::Move { location, .. }
15102 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
15103 )),
15104 "armor slots can't hold other items and must not appear as move destinations"
15105 );
15106 let belt_opt = opts
15107 .iter()
15108 .find(|o| matches!(
15109 &o.kind,
15110 MoveOptionKind::Move { location, .. }
15111 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
15112 ))
15113 .unwrap();
15114 assert!(belt_opt.label.contains("belt loop"));
15115
15116 let opts = state.move_destinations_for(
15117 &flatland_protocol::InventoryLocation::Root,
15118 None,
15119 None,
15120 "lumber",
15121 );
15122 assert!(
15123 !opts.iter().any(|o| o.label.contains("belt loop")),
15124 "loose materials must not target the belt shell — only nested pouches"
15125 );
15126 }
15127
15128 #[test]
15129 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
15130 let mut state = sample_state();
15131 let belt_id = uuid::Uuid::from_u128(30);
15132 let pouch_id = uuid::Uuid::from_u128(31);
15133 state.worn.insert(
15134 BodySlot::Waist,
15135 flatland_protocol::ItemStack {
15136 template_id: "simple_belt".into(),
15137 quantity: 1,
15138 item_instance_id: Some(belt_id),
15139 props: Default::default(),
15140 status_bindings: Vec::new(),
15141 world_placeable: None,
15142 worker_lodging_capacity: None,
15143 equip_slot: None,
15144 armor_physical: None,
15145 resists: vec![],
15146 hand_slots: None,
15147 contents: vec![flatland_protocol::ItemStack {
15148 template_id: "dimensional_pouch".into(),
15149 quantity: 1,
15150 item_instance_id: Some(pouch_id),
15151 props: Default::default(),
15152 status_bindings: Vec::new(),
15153 contents: Vec::new(),
15154 display_name: Some("Dimensional Pouch".into()),
15155 category: Some("container".into()),
15156 base_mass: None,
15157 base_volume: None,
15158 capacity_volume: Some(200.0),
15159 stackable: None,
15160 world_placeable: None,
15161 worker_lodging_capacity: None,
15162 equip_slot: None,
15163 armor_physical: None,
15164 resists: vec![],
15165 hand_slots: None,
15166 listable: None,
15167 }],
15168 display_name: Some("Simple Belt".into()),
15169 category: Some("container".into()),
15170 base_mass: None,
15171 base_volume: None,
15172 capacity_volume: None,
15173 stackable: None,
15174 listable: None,
15175 },
15176 );
15177
15178 let opts = state.move_destinations_for(
15179 &flatland_protocol::InventoryLocation::Root,
15180 None,
15181 None,
15182 "iron_ore",
15183 );
15184 assert!(
15185 opts.iter().any(|o| matches!(
15186 &o.kind,
15187 MoveOptionKind::Move {
15188 location,
15189 parent_instance_id,
15190 ..
15191 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
15192 && *parent_instance_id == Some(pouch_id)
15193 )),
15194 "dimensional pouch clipped on belt must accept loose items"
15195 );
15196 assert!(
15197 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
15198 "destination label should name the pouch"
15199 );
15200 }
15201
15202 #[test]
15203 fn container_volume_label_on_placed_chest_shell() {
15204 let mut state = sample_state();
15205 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15206 id: "chest-1".into(),
15207 template_id: "wooden_chest_small".into(),
15208 display_name: "Camp Chest".into(),
15209 x: 129.0,
15210 y: 128.0,
15211 z: 0.0,
15212 locked: false,
15213 accessible: true,
15214 owner_character_id: None,
15215 contents: vec![flatland_protocol::ItemStack {
15216 template_id: "iron_ore".into(),
15217 quantity: 2,
15218 item_instance_id: None,
15219 props: Default::default(),
15220 status_bindings: Vec::new(),
15221 contents: Vec::new(),
15222 display_name: None,
15223 category: None,
15224 base_mass: None,
15225 base_volume: Some(2.0),
15226 capacity_volume: None,
15227 stackable: None,
15228 world_placeable: None,
15229 worker_lodging_capacity: None,
15230 equip_slot: None,
15231 armor_physical: None,
15232 resists: vec![],
15233 hand_slots: None,
15234 listable: None,
15235 }],
15236 lock_id: None,
15237 capacity_volume: Some(60.0),
15238 item_instance_id: Some(uuid::Uuid::from_u128(4)),
15239 tile_id: None,
15240 worker_lodging_capacity: None,
15241 blocking: false,
15242 blocking_radius_m: 0.0,
15243 }];
15244 let nearby = state.nearby_containers();
15245 let label = state.container_volume_label(&nearby[0].rows[0]);
15246 assert!(
15247 label.contains("vol 4/60"),
15248 "expected used/cap in label, got {label}"
15249 );
15250 assert!(
15251 label.contains("56 free"),
15252 "expected free space, got {label}"
15253 );
15254 }
15255
15256 #[test]
15257 fn key_pair_chest_label_from_placed_lock_id() {
15258 let mut state = sample_state();
15259 let owner = uuid::Uuid::from_u128(77);
15260 state.character_id = Some(owner);
15261 let lock = uuid::Uuid::from_u128(99).to_string();
15262 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15263 id: "chest-1".into(),
15264 template_id: "wooden_chest_small".into(),
15265 display_name: "Barry's Loot #a3f2".into(),
15266 x: 129.0,
15267 y: 128.0,
15268 z: 0.0,
15269 locked: true,
15270 accessible: true,
15271 owner_character_id: Some(owner),
15272 contents: Vec::new(),
15273 lock_id: Some(lock.clone()),
15274 capacity_volume: None,
15275 item_instance_id: Some(uuid::Uuid::from_u128(4)),
15276 tile_id: None,
15277 worker_lodging_capacity: None,
15278 blocking: false,
15279 blocking_radius_m: 0.0,
15280 }];
15281 let key_id = uuid::Uuid::from_u128(5);
15282 let key = flatland_protocol::ItemStack {
15283 template_id: KEY_TEMPLATE.into(),
15284 quantity: 1,
15285 item_instance_id: Some(key_id),
15286 props: BTreeMap::from([
15287 (PROP_OPENS_LOCK_ID.into(), lock),
15288 (
15289 PROP_OPENS_CONTAINER_NAME.into(),
15290 "Barry's Loot #a3f2".into(),
15291 ),
15292 ]),
15293 status_bindings: Vec::new(),
15294 contents: Vec::new(),
15295 display_name: Some("Container Key".into()),
15296 category: Some("key".into()),
15297 base_mass: None,
15298 base_volume: None,
15299 capacity_volume: None,
15300 stackable: None,
15301 world_placeable: None,
15302 worker_lodging_capacity: None,
15303 equip_slot: None,
15304 armor_physical: None,
15305 resists: vec![],
15306 hand_slots: None,
15307 listable: None,
15308 };
15309 state.inventory_stacks = vec![key.clone()];
15310 assert_eq!(
15311 state.key_pair_chest_label(&key).as_deref(),
15312 Some("Barry's Loot #a3f2")
15313 );
15314 assert!(state.key_drop_blocked(&key));
15315 }
15316
15317 #[test]
15318 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
15319 let mut state = sample_state();
15320 let lock = uuid::Uuid::from_u128(101).to_string();
15321 let key = flatland_protocol::ItemStack {
15322 template_id: KEY_TEMPLATE.into(),
15323 quantity: 1,
15324 item_instance_id: Some(uuid::Uuid::from_u128(7)),
15325 props: BTreeMap::from([
15326 (PROP_OPENS_LOCK_ID.into(), lock),
15327 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
15328 ]),
15329 status_bindings: Vec::new(),
15330 contents: Vec::new(),
15331 display_name: None,
15332 category: Some("key".into()),
15333 base_mass: None,
15334 base_volume: None,
15335 capacity_volume: None,
15336 stackable: None,
15337 world_placeable: None,
15338 worker_lodging_capacity: None,
15339 equip_slot: None,
15340 armor_physical: None,
15341 resists: vec![],
15342 hand_slots: None,
15343 listable: None,
15344 };
15345 state.placed_containers.clear();
15346 assert_eq!(
15347 state.key_pair_chest_label(&key).as_deref(),
15348 Some("Camp Stash")
15349 );
15350 }
15351
15352 #[test]
15353 fn key_drop_allowed_when_paired_chest_unlocked() {
15354 let mut state = sample_state();
15355 let lock = uuid::Uuid::from_u128(100).to_string();
15356 let key_id = uuid::Uuid::from_u128(6);
15357 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15358 id: "chest-1".into(),
15359 template_id: "wooden_chest_small".into(),
15360 display_name: "Camp Chest".into(),
15361 x: 129.0,
15362 y: 128.0,
15363 z: 0.0,
15364 locked: false,
15365 accessible: true,
15366 owner_character_id: None,
15367 contents: Vec::new(),
15368 lock_id: Some(lock.clone()),
15369 capacity_volume: None,
15370 item_instance_id: None,
15371 tile_id: None,
15372 worker_lodging_capacity: None,
15373 blocking: false,
15374 blocking_radius_m: 0.0,
15375 }];
15376 let key = flatland_protocol::ItemStack {
15377 template_id: KEY_TEMPLATE.into(),
15378 quantity: 1,
15379 item_instance_id: Some(key_id),
15380 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
15381 status_bindings: Vec::new(),
15382 contents: Vec::new(),
15383 display_name: None,
15384 category: Some("key".into()),
15385 base_mass: None,
15386 base_volume: None,
15387 capacity_volume: None,
15388 stackable: None,
15389 world_placeable: None,
15390 worker_lodging_capacity: None,
15391 equip_slot: None,
15392 armor_physical: None,
15393 resists: vec![],
15394 hand_slots: None,
15395 listable: None,
15396 };
15397 state.inventory_stacks = vec![key.clone()];
15398 assert!(!state.key_drop_blocked(&key));
15399 let opts = state.move_destinations_for(
15400 &flatland_protocol::InventoryLocation::Root,
15401 None,
15402 Some(key_id),
15403 KEY_TEMPLATE,
15404 );
15405 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
15406 }
15407
15408 #[test]
15409 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
15410 use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
15411
15412 let mut state = sample_state();
15413 let curve = ProgressionCurve::default();
15414 let bootstrap = ProgressionXp::bootstrap_new(
15415 curve.baseline_display,
15416 curve.xp_base,
15417 curve.xp_growth,
15418 );
15419 let mut fresh = bootstrap.clone();
15420 fresh.strength += 0.08;
15421 if let Some(player) = state.player.as_mut() {
15422 player.progression_xp = Some(bootstrap);
15423 }
15424
15425 let combat = CombatHud {
15426 progression_xp: Some(fresh.clone()),
15427 progression_baseline: curve.baseline_display,
15428 progression_xp_base: curve.xp_base,
15429 progression_xp_growth: curve.xp_growth,
15430 attributes: state.player.as_ref().and_then(|p| p.attributes),
15431 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
15432 ..CombatHud::default()
15433 };
15434 state.apply_combat_hud(&combat);
15435
15436 let xp = state
15437 .player
15438 .as_ref()
15439 .and_then(|p| p.progression_xp.as_ref())
15440 .expect("xp");
15441 assert!((xp.strength - fresh.strength).abs() < 0.001);
15442 assert!(state.progression_curve.is_some());
15443 }
15444
15445 #[test]
15446 fn combat_hud_syncs_known_abilities_and_hotbar() {
15447 use flatland_protocol::CombatHud;
15448
15449 let mut state = sample_state();
15450 let combat = CombatHud {
15451 known_abilities: vec!["unarmed".into(), "fireball".into()],
15452 hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
15453 max_abilities_per_rotation: 4,
15454 ability_id: "short_sword_slash".into(),
15455 ..CombatHud::default()
15456 };
15457 state.apply_combat_hud(&combat);
15458
15459 assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
15460 assert_eq!(state.hotbar_ability(1), Some("fireball"));
15461 assert_eq!(state.hotbar_ability(2), None);
15462 assert_eq!(state.hotbar_ability(3), Some("unarmed"));
15463 assert_eq!(state.max_abilities_per_rotation, 4);
15464 let choices = state.loadout_ability_choices();
15465 assert!(choices.iter().any(|a| a == "short_sword_slash"));
15466 assert!(choices.iter().any(|a| a == "fireball"));
15467 }
15468
15469 #[test]
15470 fn loadout_hotbar_choices_include_inventory_consumables() {
15471 let mut state = sample_state();
15472 state.known_abilities = vec!["unarmed".into()];
15473 state.weapon_ability_id = "unarmed".into();
15474 state.inventory_stacks = vec![flatland_protocol::ItemStack {
15475 template_id: "bottle_of_water".into(),
15476 quantity: 3,
15477 item_instance_id: Some(uuid::Uuid::from_u128(9)),
15478 display_name: Some("Bottle of Water".into()),
15479 category: Some("consumable".into()),
15480 ..Default::default()
15481 }];
15482 state.inventory.insert("bottle_of_water".into(), 3);
15483 state.inventory_hints.insert(
15484 "bottle_of_water".into(),
15485 InventoryHint {
15486 display_name: "Bottle of Water".into(),
15487 category: "consumable".into(),
15488 ..Default::default()
15489 },
15490 );
15491
15492 let choices = state.loadout_hotbar_choices();
15493 assert!(choices.iter().any(|c| c.binding == "unarmed"));
15494 let water = choices
15495 .iter()
15496 .find(|c| c.binding == "item:bottle_of_water")
15497 .expect("water binding");
15498 assert_eq!(water.meta.as_deref(), Some("use"));
15499 assert!(water.label.contains("Water"));
15500 assert_eq!(
15501 state.hotbar_slot_label(1),
15502 None,
15503 "unbound until set"
15504 );
15505 state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
15506 assert_eq!(
15507 state.hotbar_slot_label(5).as_deref(),
15508 Some("Bottle of Water×3")
15509 );
15510 }
15511
15512 #[test]
15513 fn loose_consumable_move_picker_offers_use_and_storage() {
15514 let mut state = sample_state();
15515 let inst = uuid::Uuid::from_u128(77);
15516 state.inventory_stacks = vec![flatland_protocol::ItemStack {
15517 template_id: "carrot".into(),
15518 quantity: 2,
15519 item_instance_id: Some(inst),
15520 props: Default::default(),
15521 status_bindings: Vec::new(),
15522 contents: Vec::new(),
15523 display_name: Some("Wild Carrot".into()),
15524 category: Some("consumable".into()),
15525 base_mass: None,
15526 base_volume: None,
15527 capacity_volume: None,
15528 stackable: Some(true),
15529 world_placeable: None,
15530 worker_lodging_capacity: None,
15531 equip_slot: None,
15532 armor_physical: None,
15533 resists: vec![],
15534 hand_slots: None,
15535 listable: None,
15536 }];
15537 state.inventory_hints.insert(
15538 "carrot".into(),
15539 InventoryHint {
15540 display_name: "Wild Carrot".into(),
15541 category: "consumable".into(),
15542 base_mass: Some(0.15),
15543 base_volume: Some(0.3),
15544 capacity_volume: None,
15545 stackable: true,
15546 listable: true,
15547 },
15548 );
15549 state.show_inventory_menu = true;
15550 state.inventory_menu_index = 0;
15551
15552 let row = state.inventory_selected_row().expect("carrot row");
15553 let mut options = state.move_destinations_for(
15554 &row.from,
15555 row.from_parent_instance_id,
15556 row.stack.item_instance_id,
15557 &row.stack.template_id,
15558 );
15559 if row.from == flatland_protocol::InventoryLocation::Root
15560 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
15561 {
15562 options.insert(
15563 0,
15564 MoveOption {
15565 label: "Use (eat / drink)".into(),
15566 kind: MoveOptionKind::Use,
15567 },
15568 );
15569 }
15570
15571 assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
15572 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
15573 assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
15574 }
15575
15576 #[test]
15577 fn inventory_category_group_order_is_stable() {
15578 assert_eq!(inventory_category_group("weapon").0, "Weapons");
15579 assert_eq!(inventory_category_group("armor").0, "Armor");
15580 assert_eq!(inventory_category_group("consumable").0, "Consumables");
15581 assert_eq!(inventory_category_group("resource").0, "Resources");
15582 assert_eq!(inventory_category_group("container").0, "Containers");
15583 assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
15584 assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
15585 }
15586
15587 #[test]
15588 fn page_list_index_clamps_without_wrap() {
15589 assert_eq!(page_list_index(0, -1, 25), 0);
15590 assert_eq!(page_list_index(0, 1, 25), 10);
15591 assert_eq!(page_list_index(12, 1, 25), 22);
15592 assert_eq!(page_list_index(22, 1, 25), 24);
15593 assert_eq!(page_list_index(5, 1, 0), 0);
15594 assert_eq!(page_list_index(3, -1, 8), 0);
15595 }
15596
15597 #[test]
15598 fn inventory_filter_hides_non_matching_person_items() {
15599 let mut state = sample_state();
15600 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
15601 sword.display_name = Some("Iron Sword".into());
15602 sword.category = Some("weapon".into());
15603 let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
15604 herb.display_name = Some("Wild Herb".into());
15605 herb.category = Some("consumable".into());
15606 state.inventory_stacks = vec![sword, herb];
15607 state.inventory_tab = InventoryTab::OnPerson;
15608 state.inventory_filter = "sword".into();
15609
15610 let rows = state.inventory_selectable_rows();
15611 assert_eq!(rows.len(), 1);
15612 assert_eq!(rows[0].stack.template_id, "iron_sword");
15613
15614 let lines = state.inventory_browser_lines();
15615 assert!(lines.iter().any(|l| matches!(
15616 l,
15617 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
15618 )));
15619 assert!(!lines.iter().any(|l| matches!(
15620 l,
15621 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
15622 )));
15623 }
15624
15625 #[test]
15626 fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
15627 let mut state = sample_state();
15628 let id_a = uuid::Uuid::from_u128(0xa1);
15629 let id_b = uuid::Uuid::from_u128(0xb2);
15630 let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
15631 sword_a.display_name = Some("Iron Sword".into());
15632 sword_a.category = Some("weapon".into());
15633 sword_a.item_instance_id = Some(id_a);
15634 let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
15635 sword_b.display_name = Some("Iron Sword".into());
15636 sword_b.category = Some("weapon".into());
15637 sword_b.item_instance_id = Some(id_b);
15638 state.inventory_stacks = vec![sword_a, sword_b];
15639 state.inventory_tab = InventoryTab::OnPerson;
15640
15641 let lines = state.inventory_browser_lines();
15642 let items: Vec<_> = lines
15643 .iter()
15644 .filter_map(|l| match l {
15645 InventoryBrowserLine::Item {
15646 title,
15647 instance_tooltip,
15648 ..
15649 } => Some((title.clone(), instance_tooltip.clone())),
15650 _ => None,
15651 })
15652 .collect();
15653 assert_eq!(items.len(), 2);
15654 for (title, tip) in &items {
15655 assert!(
15656 !title.contains('#'),
15657 "title should not show instance suffix: {title}"
15658 );
15659 assert!(
15660 tip.is_some(),
15661 "two identical rows should expose instance on hover"
15662 );
15663 }
15664
15665 state.inventory_stacks.pop();
15666 let lines = state.inventory_browser_lines();
15667 let one = lines.iter().find_map(|l| match l {
15668 InventoryBrowserLine::Item {
15669 title,
15670 instance_tooltip,
15671 ..
15672 } => Some((title.clone(), instance_tooltip.clone())),
15673 _ => None,
15674 });
15675 let (title, tip) = one.expect("one sword row");
15676 assert!(!title.contains('#'));
15677 assert!(tip.is_none(), "single row should not need instance tooltip");
15678 }
15679
15680 #[test]
15681 fn inventory_person_rows_group_by_category() {
15682 let mut state = sample_state();
15683 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
15684 sword.category = Some("weapon".into());
15685 sword.display_name = Some("Iron Sword".into());
15686 let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
15687 ore.category = Some("resource".into());
15688 ore.display_name = Some("Iron Ore".into());
15689 let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
15690 potion.category = Some("consumable".into());
15691 potion.display_name = Some("Health Potion".into());
15692 state.inventory_stacks = vec![ore, potion, sword];
15693 state.inventory_tab = InventoryTab::OnPerson;
15694
15695 let lines = state.inventory_browser_lines();
15696 let labels: Vec<&str> = lines
15697 .iter()
15698 .filter_map(|l| match l {
15699 InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
15700 _ => None,
15701 })
15702 .collect();
15703 assert!(
15704 labels.iter().any(|s| s.contains("Weapons")),
15705 "expected Weapons group: {labels:?}"
15706 );
15707 assert!(labels.iter().any(|s| s.contains("Consumables")));
15708 assert!(labels.iter().any(|s| s.contains("Resources")));
15709
15710 let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
15711 let consumable_pos = labels.iter().position(|s| s.contains("Consumables")).unwrap();
15712 let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
15713 assert!(weapon_pos < consumable_pos);
15714 assert!(consumable_pos < resource_pos);
15715 }
15716
15717 #[test]
15718 fn inventory_tab_cycle_resets_selection() {
15719 let mut state = sample_state();
15720 state.inventory_tab = InventoryTab::OnPerson;
15721 state.inventory_menu_index = 3;
15722 state.inventory_tab = state.inventory_tab.cycle(true);
15723 assert_eq!(state.inventory_tab, InventoryTab::Nearby);
15724 assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
15726 assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
15727 assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
15728 assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
15729 }
15730
15731 #[test]
15732 fn parse_bank_copper_amount_blank_and_zero_mean_all() {
15733 assert_eq!(parse_bank_copper_amount(""), Some(0));
15734 assert_eq!(parse_bank_copper_amount(" "), Some(0));
15735 assert_eq!(parse_bank_copper_amount("0"), Some(0));
15736 assert_eq!(parse_bank_copper_amount("250"), Some(250));
15737 assert_eq!(parse_bank_copper_amount("nope"), None);
15738 }
15739
15740 #[test]
15741 fn parse_storage_quantity_blank_and_zero_mean_all() {
15742 assert_eq!(parse_storage_quantity(""), Some(None));
15743 assert_eq!(parse_storage_quantity(" "), Some(None));
15744 assert_eq!(parse_storage_quantity("0"), Some(None));
15745 assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
15746 assert_eq!(parse_storage_quantity("nope"), None);
15747 }
15748
15749 #[test]
15750 fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
15751 assert!(worker_error_is_hud_noise("path stuck — repathing"));
15752 assert!(worker_error_is_hud_noise("path stuck — nudged clear, repathing"));
15753 assert!(worker_error_is_hud_noise("returned to lodging after path failures"));
15754 assert!(!worker_error_is_hud_noise(
15756 "path stuck — no lodging to reset to"
15757 ));
15758 }
15759}