1use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
2use std::time::{Duration, Instant};
3
4use flatland_protocol::{
5 AbilityCooldownHud, BlueprintView, BodySlot, BuildingView, CastProgressHud, CombatCueKind,
6 CombatFxHitOutcome, CombatFxKind, CombatHud, CombatSlotHud, CombatTargetHud, DoorView,
7 EntityId, EntityState, Intent, InteriorMapView, LifeState, NpcView, RotationPreset, Seq,
8 SessionId, TerrainKindView, TerrainZoneView, Tick, 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 = 2.0;
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);
117const WORKER_HEALTH_RING_HOLD: Duration = Duration::from_secs(6);
119
120#[derive(Debug, Clone, Default)]
122pub struct InventoryHint {
123 pub display_name: String,
124 pub category: String,
125 pub base_mass: Option<f32>,
126 pub base_volume: Option<f32>,
127 pub capacity_volume: Option<f32>,
128 pub stackable: bool,
129 pub listable: bool,
131 pub base_value_copper: Option<u32>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct LoadoutHotbarChoice {
138 pub binding: String,
140 pub label: String,
142 pub meta: Option<String>,
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
148pub enum RotationEditorMode {
149 #[default]
150 List,
151 EditSequence,
152 PickAbility,
153 EditLabel,
154}
155
156#[derive(Debug, Clone, Default)]
158pub struct RotationEditorState {
159 pub mode: RotationEditorMode,
160 pub list_index: usize,
161 pub ability_index: usize,
162 pub picker_index: usize,
163 pub draft: Option<RotationPreset>,
164 pub label_buffer: String,
165}
166
167impl RotationEditorState {
168 pub fn reset(&mut self) {
169 *self = Self::default();
170 }
171}
172
173pub const CONTAINER_RANGE_M: f32 = 3.0;
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum InventorySection {
183 Worn,
185 Person,
187 Nearby,
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
193pub enum InventoryTab {
194 #[default]
195 OnPerson,
196 Nearby,
197}
198
199impl InventoryTab {
200 pub fn label(self) -> &'static str {
201 match self {
202 Self::OnPerson => "On person",
203 Self::Nearby => "Nearby storage",
204 }
205 }
206
207 pub fn cycle(self, forward: bool) -> Self {
208 match (self, forward) {
209 (Self::OnPerson, true) | (Self::OnPerson, false) => Self::Nearby,
210 (Self::Nearby, true) | (Self::Nearby, false) => Self::OnPerson,
211 }
212 }
213}
214
215pub const LIST_PAGE_SIZE: usize = 10;
217
218pub fn list_label_matches(haystack: &str, filter: &str) -> bool {
220 if filter.is_empty() {
221 return true;
222 }
223 haystack
224 .to_ascii_lowercase()
225 .contains(&filter.to_ascii_lowercase())
226}
227
228pub fn page_list_index(index: usize, pages: i32, len: usize) -> usize {
230 if len == 0 {
231 return 0;
232 }
233 let page = LIST_PAGE_SIZE as i32;
234 let next = index as i32 + pages * page;
235 next.clamp(0, (len as i32) - 1) as usize
236}
237
238pub fn step_filtered_index(
240 index: usize,
241 delta: i32,
242 len: usize,
243 pred: impl Fn(usize) -> bool,
244) -> usize {
245 if len == 0 {
246 return 0;
247 }
248 let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
249 if matching.is_empty() {
250 return index.min(len - 1);
251 }
252 let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
253 let next = (pos as i32 + delta).rem_euclid(matching.len() as i32) as usize;
254 matching[next]
255}
256
257pub fn page_filtered_index(
259 index: usize,
260 pages: i32,
261 len: usize,
262 pred: impl Fn(usize) -> bool,
263) -> usize {
264 if len == 0 {
265 return 0;
266 }
267 let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
268 if matching.is_empty() {
269 return index.min(len - 1);
270 }
271 let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
272 let next = page_list_index(pos, pages, matching.len());
273 matching[next]
274}
275
276pub fn inventory_category_group(category: &str) -> (&'static str, u8) {
278 match category {
279 "weapon" | "ammo" => ("Weapons", 0),
280 "armor" | "shield" | "offhand" => ("Armor", 1),
281 "consumable" => ("Consumables", 2),
282 "resource" | "harvest_node" | "seed" => ("Resources", 3),
283 "container" | "lodging" => ("Containers", 4),
284 "currency" | "key" => ("Currency & keys", 5),
285 "tool" | "misc" | "furniture" | "quest" | "document" => ("Gear & misc", 6),
286 _ => ("Other", 7),
287 }
288}
289
290pub fn category_default_listable(category: &str) -> bool {
292 !matches!(
293 category,
294 "currency" | "harvest_node" | "key" | "quest" | "document" | "lodging"
295 )
296}
297
298pub fn npc_market_dump_unit_estimate_copper(base_value: u32) -> Option<u32> {
300 if base_value == 0 {
301 return None;
302 }
303 let unit = ((base_value as f32) * 0.5).floor() as u32;
304 if unit == 0 {
305 return None;
306 }
307 Some(((unit as u64).saturating_mul(9500) / 10_000).max(1) as u32)
308}
309
310fn parse_bank_copper_amount(input: &str) -> Option<u64> {
312 let s = input.trim();
313 if s.is_empty() {
314 return Some(0);
315 }
316 s.parse::<u64>().ok()
317}
318
319fn parse_storage_quantity(input: &str) -> Option<Option<u32>> {
321 let s = input.trim();
322 if s.is_empty() || s == "0" {
323 return Some(None);
324 }
325 let n = s.parse::<u32>().ok()?;
326 if n == 0 {
327 return Some(None);
328 }
329 Some(Some(n))
330}
331
332fn storage_stack_label(stack: &flatland_protocol::ItemStack) -> String {
333 let name = stack
334 .display_name
335 .as_deref()
336 .unwrap_or(stack.template_id.as_str());
337 if stack.quantity > 1 {
338 format!("{name} ×{}", stack.quantity)
339 } else {
340 name.to_string()
341 }
342}
343
344pub fn body_slot_label(slot: BodySlot) -> &'static str {
347 match slot {
348 BodySlot::Head => "Head",
349 BodySlot::Chest => "Chest",
350 BodySlot::Forearms => "Forearms",
351 BodySlot::Legs => "Legs",
352 BodySlot::Feet => "Feet",
353 BodySlot::Cloak => "Cloak",
354 BodySlot::Back => "Back",
355 BodySlot::Waist => "Waist",
356 BodySlot::Earrings => "Earrings",
357 BodySlot::Necklace => "Necklace",
358 BodySlot::Eyeglasses => "Eyeglasses",
359 BodySlot::RingLeft1 => "Ring L1",
360 BodySlot::RingLeft2 => "Ring L2",
361 BodySlot::RingRight1 => "Ring R1",
362 BodySlot::RingRight2 => "Ring R2",
363 }
364}
365
366fn grant_target_matches_mode(stack: &flatland_protocol::ItemStack, mode: &str) -> bool {
367 let cat = stack.category.as_deref().unwrap_or("");
368 match mode {
369 "while_equipped" => {
370 stack.equip_slot.is_some()
371 || cat == "weapon"
372 || cat == "shield"
373 || cat == "offhand"
374 || cat == "armor"
375 }
376 _ => cat == "weapon" || cat == "ammo" || stack.props.contains_key("weapon_ability_id"),
377 }
378}
379
380fn grant_tags_match(stack: &flatland_protocol::ItemStack, grant_tags: &[&str]) -> bool {
381 if grant_tags.is_empty() {
382 return true;
383 }
384 let target_tags: Vec<&str> = stack
385 .props
386 .get("allowed_enchant_tags")
387 .map(|s| {
388 s.split(',')
389 .map(str::trim)
390 .filter(|t| !t.is_empty())
391 .collect()
392 })
393 .unwrap_or_default();
394 if target_tags.is_empty() {
395 return true;
396 }
397 grant_tags.iter().any(|t| target_tags.contains(t))
398}
399
400pub const DEFAULT_TICK_HZ: u32 = 30;
402
403pub fn format_binding_ttl(
405 binding: &flatland_protocol::ItemStatusBinding,
406 tick: u64,
407 tick_hz: u32,
408) -> String {
409 let Some(expires) = binding.expires_at_tick else {
410 return "permanent".into();
411 };
412 let hz = tick_hz.max(1) as f32;
413 let remaining = expires.saturating_sub(tick) as f32 / hz;
414 if remaining <= 0.0 {
415 return "expired".into();
416 }
417 if remaining >= 120.0 {
418 format!("{:.0}m left", remaining / 60.0)
419 } else if remaining >= 10.0 {
420 format!("{remaining:.0}s left")
421 } else {
422 format!("{remaining:.1}s left")
423 }
424}
425
426pub fn format_binding_mode(mode: flatland_protocol::ItemStatusBindingMode) -> &'static str {
427 match mode {
428 flatland_protocol::ItemStatusBindingMode::OnHit => "on hit",
429 flatland_protocol::ItemStatusBindingMode::WhileEquipped => "while equipped",
430 }
431}
432
433pub fn format_status_bindings_suffix(
435 bindings: &[flatland_protocol::ItemStatusBinding],
436 tick: u64,
437 tick_hz: u32,
438) -> String {
439 if bindings.is_empty() {
440 return String::new();
441 }
442 let parts: Vec<String> = bindings
443 .iter()
444 .map(|b| {
445 format!(
446 "{} ({}, {})",
447 b.effect_id,
448 format_binding_mode(b.mode),
449 format_binding_ttl(b, tick, tick_hz)
450 )
451 })
452 .collect();
453 format!(" · {}", parts.join("; "))
454}
455
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457pub enum EquipPaperdollRow {
458 Body { slot: BodySlot, filled: bool },
459 Mainhand { filled: bool },
460 Offhand { filled: bool, locked: bool },
461}
462
463pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
464 let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
465 .iter()
466 .map(|slot| EquipPaperdollRow::Body {
467 slot: *slot,
468 filled: state.worn.contains_key(slot),
469 })
470 .collect();
471 let two_hand = state.mainhand_hand_slots >= 2;
472 rows.push(EquipPaperdollRow::Mainhand {
473 filled: state.mainhand_template_id.is_some(),
474 });
475 rows.push(EquipPaperdollRow::Offhand {
476 filled: state.offhand_template_id.is_some(),
477 locked: two_hand,
478 });
479 rows
480}
481
482fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
483 for stack in &state.inventory_stacks {
484 let matches = stack
485 .equip_slot
486 .map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
487 .unwrap_or(false)
488 || guess_body_slot(&stack.template_id) == Some(slot);
489 if matches {
490 return stack.item_instance_id;
491 }
492 }
493 None
494}
495
496fn is_client_ring(slot: BodySlot) -> bool {
497 matches!(
498 slot,
499 BodySlot::RingLeft1 | BodySlot::RingLeft2 | BodySlot::RingRight1 | BodySlot::RingRight2
500 )
501}
502
503fn first_inventory_weapon(state: &GameState) -> Option<String> {
504 for stack in &state.inventory_stacks {
505 if stack.category.as_deref() == Some("weapon") {
506 return Some(stack.template_id.clone());
507 }
508 }
509 None
510}
511
512fn first_inventory_offhand(state: &GameState) -> Option<String> {
513 for stack in &state.inventory_stacks {
514 let cat = stack.category.as_deref().unwrap_or("");
515 if matches!(cat, "shield" | "offhand") {
516 return Some(stack.template_id.clone());
517 }
518 }
519 None
520}
521
522fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
525 if template_id.contains("backpack") {
526 Some(BodySlot::Back)
527 } else if template_id.contains("belt") {
528 Some(BodySlot::Waist)
529 } else if template_id.contains("cloak") || template_id.contains("cape") {
530 Some(BodySlot::Cloak)
531 } else if template_id.contains("cap")
532 || template_id.contains("hat")
533 || template_id.contains("helm")
534 {
535 Some(BodySlot::Head)
536 } else if template_id.contains("shirt")
537 || template_id.contains("robe")
538 || template_id.contains("vest")
539 || template_id.contains("chest")
540 || template_id.contains("jerkin")
541 {
542 Some(BodySlot::Chest)
543 } else if template_id.contains("sleeves")
544 || template_id.contains("gloves")
545 || template_id.contains("gauntlets")
546 {
547 Some(BodySlot::Forearms)
548 } else if template_id.contains("pants") || template_id.contains("leggings") {
549 Some(BodySlot::Legs)
550 } else if template_id.contains("boots") || template_id.contains("shoes") {
551 Some(BodySlot::Feet)
552 } else if template_id.contains("earring") {
553 Some(BodySlot::Earrings)
554 } else if template_id.contains("necklace") || template_id.contains("amulet") {
555 Some(BodySlot::Necklace)
556 } else if template_id.contains("glass")
557 || template_id.contains("spectacles")
558 || template_id.contains("goggles")
559 {
560 Some(BodySlot::Eyeglasses)
561 } else if template_id.contains("ring") {
562 Some(BodySlot::RingLeft1)
563 } else {
564 None
565 }
566}
567
568#[derive(Debug, Clone)]
570pub struct InventoryRow {
571 pub depth: usize,
572 pub stack: flatland_protocol::ItemStack,
573 pub from: flatland_protocol::InventoryLocation,
575 pub from_parent_instance_id: Option<uuid::Uuid>,
577 pub is_equip_shell: bool,
579 pub is_chest_shell: bool,
581 pub section: InventorySection,
582}
583
584#[derive(Debug, Clone)]
586pub struct InventoryRowView {
587 pub depth: usize,
588 pub text: String,
590 pub title: String,
592 pub mass_kg: Option<f32>,
593 pub volume: Option<(f32, f32)>,
594 pub instance_tooltip: Option<String>,
596}
597
598#[derive(Debug, Clone)]
600pub enum InventoryBrowserLine {
601 Section(String),
602 SlotLabel(String),
603 Hint(String),
604 Blank,
605 Item {
606 selectable_index: usize,
607 selected: bool,
608 depth: usize,
609 text: String,
610 title: String,
611 mass_kg: Option<f32>,
612 volume: Option<(f32, f32)>,
613 instance_tooltip: Option<String>,
614 },
615}
616
617#[derive(Debug, Clone, PartialEq, Eq, Default)]
619pub enum BankUiMode {
620 #[default]
621 Menu,
622 DepositAmount {
623 input: String,
624 },
625 WithdrawAmount {
626 input: String,
627 },
628 TransferName {
629 input: String,
630 },
631 TransferAmount {
632 to_name: String,
633 input: String,
634 },
635}
636
637#[derive(Debug, Clone, PartialEq, Eq, Default)]
639pub enum StorageUiMode {
640 #[default]
641 Menu,
642 StorePick { index: usize },
644 StoreAmount {
646 pick_index: usize,
647 item_instance_id: uuid::Uuid,
648 label: String,
649 max_qty: u32,
650 input: String,
651 },
652 TakePick { index: usize },
654 TakeAmount {
656 pick_index: usize,
657 item_instance_id: uuid::Uuid,
658 label: String,
659 max_qty: u32,
660 input: String,
661 },
662 ShipPick {
664 dest_building_id: String,
665 dest_label: String,
666 index: usize,
667 },
668 ShipAmount {
670 dest_building_id: String,
671 dest_label: String,
672 pick_index: usize,
673 item_instance_id: uuid::Uuid,
674 label: String,
675 max_qty: u32,
676 input: String,
677 },
678}
679
680#[derive(Debug, Clone, PartialEq, Eq)]
682pub enum MarketListSourceKind {
683 Person,
684 TownStorage { building_id: String },
685}
686
687#[derive(Debug, Clone, PartialEq, Eq, Default)]
689pub enum MarketUiMode {
690 #[default]
691 Browse,
692 ListSource { index: usize },
694 ListPick {
696 source: MarketListSourceKind,
697 index: usize,
698 },
699 ListAmount {
701 source: MarketListSourceKind,
702 pick_index: usize,
703 item_instance_id: uuid::Uuid,
704 template_id: String,
705 label: String,
706 max_qty: u32,
707 input: String,
708 },
709 ListPricingMode {
711 source: MarketListSourceKind,
712 pick_index: usize,
713 item_instance_id: uuid::Uuid,
714 template_id: String,
715 label: String,
716 quantity: Option<u32>,
717 max_qty: u32,
718 index: usize,
720 },
721 ListPrice {
723 source: MarketListSourceKind,
724 pick_index: usize,
725 item_instance_id: uuid::Uuid,
726 template_id: String,
727 label: String,
728 quantity: Option<u32>,
730 max_qty: u32,
731 input: String,
732 },
733}
734
735#[derive(Debug, Clone)]
737pub struct StoragePickOption {
738 pub item_instance_id: uuid::Uuid,
739 pub template_id: String,
740 pub label: String,
741 pub quantity: u32,
742 pub category: String,
744}
745
746#[derive(Debug, Clone)]
749pub struct NearbyContainer {
750 pub view: flatland_protocol::PlacedContainerView,
751 pub distance_m: f32,
752 pub rows: Vec<InventoryRow>,
753}
754
755#[derive(Debug, Clone)]
757pub struct KeychainEntry {
758 pub stack: flatland_protocol::ItemStack,
759 pub stowed: bool,
760}
761
762#[derive(Debug, Clone)]
764pub struct MoveOption {
765 pub label: String,
766 pub kind: MoveOptionKind,
767}
768
769#[derive(Debug, Clone, PartialEq)]
770pub enum MoveOptionKind {
771 Move {
772 location: flatland_protocol::InventoryLocation,
773 parent_instance_id: Option<uuid::Uuid>,
774 },
775 PickupPlaced {
777 container_id: String,
778 nest_location: flatland_protocol::InventoryLocation,
779 nest_parent_instance_id: Option<uuid::Uuid>,
780 },
781 RelocatePlaced {
783 container_id: String,
784 },
785 Use,
787 GrantApply,
789 Drop,
790 SellPlotToCrown {
792 plot_id: uuid::Uuid,
793 },
794 Cancel,
795}
796
797#[derive(Debug, Clone, PartialEq)]
799pub enum FarmAccessRow {
800 PublicToggle,
801 PublicDiscount,
802 AllowRemove {
803 character_id: uuid::Uuid,
804 label: String,
805 tax_discount_bps: u32,
806 },
807 NearbyAdd {
808 name: String,
809 },
810}
811
812#[derive(Debug, Clone)]
814pub struct GrantTargetPicker {
815 pub grant_instance_id: uuid::Uuid,
816 pub grant_label: String,
817 pub effect_id: String,
818 pub mode: String,
819 pub options: Vec<GrantTargetOption>,
820 pub filter: String,
821 pub filter_focused: bool,
822}
823
824#[derive(Debug, Clone)]
825pub struct GrantTargetOption {
826 pub label: String,
827 pub target_instance_id: uuid::Uuid,
828}
829
830#[derive(Debug, Clone)]
832pub struct MovePicker {
833 pub item_instance_id: uuid::Uuid,
834 pub from: flatland_protocol::InventoryLocation,
835 pub item_label: String,
836 pub template_id: String,
837 pub stack_quantity: u32,
838 pub quantity: u32,
839 pub options: Vec<MoveOption>,
840 pub filter: String,
841 pub filter_focused: bool,
842}
843
844#[derive(Debug, Clone)]
846pub struct DestroyPicker {
847 pub item_instance_id: uuid::Uuid,
848 pub from: flatland_protocol::InventoryLocation,
849 pub item_label: String,
850 pub stack_quantity: u32,
851 pub quantity: u32,
852}
853
854#[derive(Debug, Clone)]
856pub struct WorkerGiveOption {
857 pub item_instance_id: uuid::Uuid,
858 pub label: String,
859 pub quantity: u32,
860 pub template_id: String,
861}
862
863#[derive(Debug, Clone)]
865pub struct WorkerGivePicker {
866 pub worker_instance_id: String,
867 pub worker_label: String,
868 pub options: Vec<WorkerGiveOption>,
869}
870
871#[derive(Debug, Clone)]
873pub struct WorkerGiveTargetOption {
874 pub instance_id: String,
875 pub label: String,
876 pub distance_m: f32,
877}
878
879#[derive(Debug, Clone)]
881pub struct WorkerGiveTargetPicker {
882 pub item_instance_id: uuid::Uuid,
883 pub item_label: String,
884 pub quantity: Option<u32>,
885 pub options: Vec<WorkerGiveTargetOption>,
886}
887
888#[derive(Debug, Clone)]
890pub struct WorkerTakePicker {
891 pub worker_instance_id: String,
892 pub worker_label: String,
893 pub options: Vec<WorkerGiveOption>,
894 pub quantity: u32,
896}
897
898pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
900
901#[derive(Debug, Clone)]
903pub struct WorkerTeachOption {
904 pub blueprint_id: String,
905 pub label: String,
906 pub cost_copper: u64,
907 pub min_level: u32,
908 pub worker_level: u32,
909 pub can_afford: bool,
910 pub level_ok: bool,
911}
912
913#[derive(Debug, Clone)]
915pub struct WorkerTeachPicker {
916 pub worker_instance_id: String,
917 pub worker_label: String,
918 pub worker_level: u32,
919 pub options: Vec<WorkerTeachOption>,
920}
921
922#[derive(Debug, Clone)]
924pub struct WorkerDismissConfirmation {
925 pub worker_instance_id: String,
926 pub worker_label: String,
927}
928
929#[derive(Debug, Clone, Default)]
932pub struct StickyWorkerStep {
933 shown: String,
934 pending: String,
935 pending_since: Option<Instant>,
936}
937
938impl StickyWorkerStep {
939 fn from_label(label: String) -> Self {
940 Self {
941 shown: label.clone(),
942 pending: label,
943 pending_since: Some(Instant::now()),
944 }
945 }
946
947 fn observe(&mut self, label: &str, now: Instant) {
948 let pending_since = self.pending_since.unwrap_or(now);
949 if label == self.pending {
950 if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD {
951 self.shown = self.pending.clone();
952 }
953 return;
954 }
955 self.pending = label.to_string();
956 self.pending_since = Some(now);
957 if self.shown.is_empty() {
959 self.shown = self.pending.clone();
960 }
961 }
962}
963
964#[derive(Debug, Clone, Default)]
967pub struct StickyWorkerError {
968 message: String,
969 last_seen: Option<Instant>,
970}
971
972impl StickyWorkerError {
973 fn observe(&mut self, err: Option<&str>, now: Instant) {
974 if let Some(e) = err {
975 if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
976 self.message = e.to_string();
977 self.last_seen = Some(now);
978 }
979 return;
980 }
981 if let Some(seen) = self.last_seen {
982 if now.duration_since(seen) > WORKER_ERROR_HOLD {
983 self.message.clear();
984 self.last_seen = None;
985 }
986 }
987 }
988
989 pub fn shown(&self, now: Instant) -> Option<&str> {
990 if self.message.is_empty() {
991 return None;
992 }
993 let seen = self.last_seen?;
994 if now.duration_since(seen) > WORKER_ERROR_HOLD {
995 return None;
996 }
997 Some(self.message.as_str())
998 }
999}
1000
1001pub fn worker_attention_line(state: &GameState) -> Option<String> {
1004 use flatland_protocol::WorkerStateView;
1005 let now = Instant::now();
1006 for w in &state.hired_workers {
1007 if matches!(w.state, WorkerStateView::Strike) {
1008 return Some(format!(
1009 "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
1010 w.label
1011 ));
1012 }
1013 let sticky = state
1014 .worker_error_display
1015 .get(&w.instance_id)
1016 .and_then(|s| s.shown(now))
1017 .filter(|e| !worker_error_is_hud_noise(e));
1018 let live = w
1019 .last_error
1020 .as_deref()
1021 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e));
1022 if let Some(err) = sticky.or(live) {
1023 if let Some(hint) = w
1024 .issue_hint
1025 .as_deref()
1026 .filter(|h| !h.is_empty())
1027 .or_else(|| worker_issue_fix_hint(err))
1028 {
1029 return Some(format!("Worker {}: {err} — {hint}", w.label));
1030 }
1031 return Some(format!("Worker {}: {err}", w.label));
1032 }
1033 if let Some(hint) = w.issue_hint.as_deref().filter(|h| !h.is_empty()) {
1035 return Some(format!("Worker {}: {hint}", w.label));
1036 }
1037 }
1038 None
1039}
1040
1041pub fn worker_issue_fix_hint(err: &str) -> Option<&'static str> {
1043 let e = err.to_ascii_lowercase();
1044 if e.contains("missing")
1045 || e.contains("container not found")
1046 || e.contains("lodging container not found")
1047 {
1048 return Some("edit route (e): replace the missing chest/bed");
1049 }
1050 if e.contains("stranded at interior") || e.contains("interior map coords") {
1051 return Some("recovered — continuing route");
1052 }
1053 if e.contains("stuck inside")
1054 || e.contains("sent outside")
1055 || e.contains("sent to door")
1056 || e.contains("left building")
1057 {
1058 return Some("auto-exit for outdoor work — restart after update if it still loops");
1059 }
1060 if e.contains("collapsed") || e.contains("need food") {
1061 return Some("stock lodging bed with food and drink");
1062 }
1063 if e.contains("overburdened") {
1064 return Some("add a deposit/sell stop, or empty their pack");
1065 }
1066 if e.contains("need a hoe") || e.contains("need a dibber") {
1067 return Some("give them the tool or withdraw it on the route");
1068 }
1069 None
1070}
1071
1072pub fn worker_error_is_transient(err: &str) -> bool {
1074 let e = err.to_ascii_lowercase();
1075 e.contains("continuing route")
1076 || e.contains("storage full")
1077 || e.starts_with("nothing to withdraw")
1078}
1079
1080pub fn worker_error_is_hud_noise(err: &str) -> bool {
1083 let e = err.to_ascii_lowercase();
1084 e.contains("returned to lodging after path")
1085 || e.contains("path failure")
1086 || e.contains("no path to")
1087 || e.contains("pathfinding")
1088 || e.contains("repathing")
1090 || e.contains("nudged clear")
1091 || e.contains("auto-recovery")
1093 || e.contains("stranded at interior map coords")
1094}
1095
1096#[derive(Debug, Clone)]
1098pub struct PendingWorkerJobAck {
1099 pub seq: u32,
1100 pub worker_instance_id: String,
1101 pub worker_label: String,
1102 pub idle: bool,
1103 pub stop_count: usize,
1104 pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1105 pub prev_mode: flatland_protocol::WorkerModeView,
1106 pub prev_step_label: String,
1107 pub prev_last_error: Option<String>,
1108}
1109
1110fn push_inventory_rows(
1111 rows: &mut Vec<InventoryRow>,
1112 depth: usize,
1113 stack: &flatland_protocol::ItemStack,
1114 from: &flatland_protocol::InventoryLocation,
1115 from_parent_instance_id: Option<uuid::Uuid>,
1116 section: InventorySection,
1117) {
1118 push_inventory_rows_filtered(
1119 rows,
1120 depth,
1121 stack,
1122 from,
1123 from_parent_instance_id,
1124 section,
1125 "",
1126 );
1127}
1128
1129fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1130 if filter.is_empty() {
1131 return true;
1132 }
1133 let f = filter.to_ascii_lowercase();
1134 let name = stack
1135 .display_name
1136 .as_deref()
1137 .unwrap_or("")
1138 .to_ascii_lowercase();
1139 let tid = stack.template_id.to_ascii_lowercase();
1140 name.contains(&f)
1141 || tid.contains(&f)
1142 || stack
1143 .contents
1144 .iter()
1145 .any(|c| stack_matches_filter(c, filter))
1146}
1147
1148fn push_inventory_rows_filtered(
1149 rows: &mut Vec<InventoryRow>,
1150 depth: usize,
1151 stack: &flatland_protocol::ItemStack,
1152 from: &flatland_protocol::InventoryLocation,
1153 from_parent_instance_id: Option<uuid::Uuid>,
1154 section: InventorySection,
1155 filter: &str,
1156) {
1157 if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1158 return;
1159 }
1160 let self_hit = filter.is_empty() || {
1161 let f = filter.to_ascii_lowercase();
1162 let name = stack
1163 .display_name
1164 .as_deref()
1165 .unwrap_or("")
1166 .to_ascii_lowercase();
1167 let tid = stack.template_id.to_ascii_lowercase();
1168 name.contains(&f) || tid.contains(&f)
1169 };
1170 rows.push(InventoryRow {
1171 depth,
1172 stack: stack.clone(),
1173 from: from.clone(),
1174 from_parent_instance_id,
1175 is_equip_shell: false,
1176 is_chest_shell: false,
1177 section,
1178 });
1179 for child in &stack.contents {
1180 if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1181 push_inventory_rows_filtered(
1182 rows,
1183 depth + 1,
1184 child,
1185 from,
1186 stack.item_instance_id,
1187 section,
1188 if self_hit { "" } else { filter },
1189 );
1190 }
1191 }
1192}
1193
1194#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1195pub enum ShopTab {
1196 #[default]
1197 Buy,
1198 Sell,
1199}
1200
1201#[derive(Debug, Clone)]
1202pub struct NpcChatState {
1203 pub npc_id: String,
1204 pub npc_label: String,
1205 pub lines: Vec<String>,
1206 pub input: String,
1207 pub pending: bool,
1208 pub talk_depth: flatland_protocol::NpcTalkDepth,
1209 pub trade_allowed: bool,
1210 pub banner: Option<String>,
1211 pub suggested_topics: Vec<String>,
1212}
1213
1214impl Default for NpcChatState {
1215 fn default() -> Self {
1216 Self {
1217 npc_id: String::new(),
1218 npc_label: String::new(),
1219 lines: Vec::new(),
1220 input: String::new(),
1221 pending: false,
1222 talk_depth: flatland_protocol::NpcTalkDepth::Full,
1223 trade_allowed: true,
1224 banner: None,
1225 suggested_topics: Vec::new(),
1226 }
1227 }
1228}
1229
1230pub fn npc_world_xy(state: &GameState, npc: &NpcView) -> (f32, f32) {
1232 npc.entity_id
1233 .and_then(|eid| state.entities.iter().find(|e| e.id == eid))
1234 .map(|e| (e.transform.position.x, e.transform.position.y))
1235 .unwrap_or((npc.x, npc.y))
1236}
1237
1238#[derive(Debug, Clone)]
1239pub struct GameState {
1240 pub session_id: SessionId,
1241 pub entity_id: EntityId,
1242 pub character_id: Option<uuid::Uuid>,
1244 pub tick: Tick,
1245 pub chunk_rev: u64,
1246 pub content_rev: u64,
1247 pub publish_rev: u64,
1248 pub entities: Vec<EntityState>,
1249 pub player: Option<EntityState>,
1250 pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1251 pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1252 pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1253 pub buildings: Vec<BuildingView>,
1254 pub doors: Vec<DoorView>,
1255 pub interior_map: Option<InteriorMapView>,
1256 pub npcs: Vec<NpcView>,
1257 pub blueprints: Vec<BlueprintView>,
1258 pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1260 pub world_x0: f32,
1262 pub world_y0: f32,
1263 pub world_width_m: f32,
1264 pub world_height_m: f32,
1265 pub terrain_zones: Vec<TerrainZoneView>,
1266 pub z_platforms: Vec<ZPlatformView>,
1267 pub z_transitions: Vec<ZTransitionView>,
1268 #[doc(hidden)]
1271 pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1272 pub world_clock: flatland_protocol::WorldClock,
1273 pub inventory: std::collections::HashMap<String, u32>,
1274 pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1275 pub logs: VecDeque<String>,
1276 pub intents_sent: u64,
1277 pub ticks_received: u64,
1278 pub connected: bool,
1279 pub disconnect_reason: Option<String>,
1280 pub show_stats: bool,
1281 pub hud_log_hidden: bool,
1283 pub show_equip_menu: bool,
1284 pub equip_menu_index: usize,
1285 pub show_craft_menu: bool,
1286 pub craft_menu_index: usize,
1287 pub craft_batch_quantity: u32,
1289 pub show_plot_build_menu: bool,
1291 pub plot_build_focus_wall: bool,
1293 pub plot_build_wall_index: usize,
1294 pub plot_build_roof_index: usize,
1295 pub show_shop_menu: bool,
1296 pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1297 pub bank_panel: Option<flatland_protocol::BankPanel>,
1298 pub bank_menu_index: usize,
1299 pub bank_ui_mode: BankUiMode,
1300 pub storage_panel: Option<flatland_protocol::StoragePanel>,
1301 pub market_panel: Option<flatland_protocol::MarketPanel>,
1302 pub market_menu_index: usize,
1304 pub market_filter: String,
1306 pub market_filter_focused: bool,
1307 pub market_category_filter: Option<&'static str>,
1309 pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1311 pub market_ui_mode: MarketUiMode,
1312 pub storage_menu_index: usize,
1313 pub storage_ui_mode: StorageUiMode,
1314 pub shop_tab: ShopTab,
1315 pub shop_menu_index: usize,
1316 pub shop_quantity: u32,
1317 pub shop_trade_log: VecDeque<String>,
1319 pub show_npc_verb_menu: bool,
1320 pub npc_verb_target: Option<String>,
1321 pub npc_verb_index: usize,
1322 pub player_verbs: crate::social::PlayerVerbState,
1324 pub social_chat: crate::social::SocialChatState,
1325 pub trade_ui: crate::social::TradeUiState,
1326 pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1327 pub show_npc_chat: bool,
1328 pub npc_chat: Option<NpcChatState>,
1329 pub show_inventory_menu: bool,
1330 pub inventory_menu_index: usize,
1331 pub inventory_tab: InventoryTab,
1332 pub inventory_filter: String,
1333 pub inventory_filter_focused: bool,
1334 pub show_move_picker: bool,
1335 pub move_picker_index: usize,
1336 pub move_picker: Option<MovePicker>,
1337 pub show_grant_picker: bool,
1338 pub grant_picker_index: usize,
1339 pub grant_picker: Option<GrantTargetPicker>,
1340 pub show_destroy_picker: bool,
1341 pub destroy_confirm_pending: bool,
1342 pub destroy_picker: Option<DestroyPicker>,
1343 pub show_rename_prompt: bool,
1345 pub rename_plot_id: Option<uuid::Uuid>,
1347 pub highlighted_plot_id: Option<uuid::Uuid>,
1349 pub show_worker_rename: bool,
1351 pub rename_buffer: String,
1352 pub combat_target: Option<EntityId>,
1354 pub combat_target_label: Option<String>,
1355 pub ground_target: Option<(f32, f32, f32)>,
1358 pub combat_fx: Vec<flatland_protocol::CombatFx>,
1360 pub ground_hazards: Vec<flatland_protocol::GroundHazardView>,
1362 pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1364 pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1366 pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1368 pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1370 pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1372 pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1374 pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1376 pub claim_mode: Option<ClaimModeState>,
1378 pub relocate_mode: Option<RelocateModeState>,
1380 pub sell_plot_confirm: Option<uuid::Uuid>,
1382 pub sell_plot_armed_at: Option<Instant>,
1384 pub show_plant_menu: bool,
1386 pub plant_menu_index: usize,
1387 pub show_farm_access: bool,
1389 pub farm_access_name_draft: String,
1391 pub farm_access_discount_bps: u32,
1393 pub farm_access_index: usize,
1395 pub plant_quantity: u32,
1396 pub in_combat: bool,
1397 pub auto_attack: bool,
1398 pub combat_has_los: bool,
1399 pub attack_cd_ticks: u64,
1400 pub gcd_ticks: u64,
1401 pub weapon_ability_id: String,
1402 pub mainhand_template_id: Option<String>,
1403 pub mainhand_label: Option<String>,
1404 pub mainhand_instance_id: Option<uuid::Uuid>,
1405 pub offhand_template_id: Option<String>,
1406 pub offhand_label: Option<String>,
1407 pub offhand_instance_id: Option<uuid::Uuid>,
1408 pub mainhand_hand_slots: u8,
1409 pub defense: Option<flatland_protocol::DefenseHud>,
1410 pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1412 pub carry_mass: f32,
1413 pub carry_mass_max: f32,
1414 pub encumbrance: flatland_protocol::EncumbranceState,
1415 pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1417 pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1419 pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1421 pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1423 pub combat_target_detail: Option<CombatTargetHud>,
1424 pub cast_progress: Option<CastProgressHud>,
1425 pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1427 pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1429 pub ability_cooldowns: Vec<AbilityCooldownHud>,
1430 pub blocking_active: bool,
1431 pub max_target_slots: u8,
1432 pub combat_slots: Vec<CombatSlotHud>,
1433 pub rotation_presets: Vec<RotationPreset>,
1434 pub known_abilities: Vec<String>,
1436 pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1438 pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1440 pub hotbar: Vec<Option<String>>,
1442 pub max_abilities_per_rotation: u8,
1444 pub show_loadout_menu: bool,
1445 pub show_keychain_menu: bool,
1446 pub keychain_menu_index: usize,
1447 pub show_rotation_editor: bool,
1448 pub loadout_menu_index: usize,
1450 pub loadout_hotbar_slot: u8,
1452 pub loadout_ability_index: usize,
1454 pub loadout_focus_presets: bool,
1456 pub rotation_editor: RotationEditorState,
1457 pub harvest_in_progress: bool,
1459 pub harvest_started_at: Option<Instant>,
1461 pub pending_craft_ack: Option<(u32, String, u32)>,
1463 pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1464 pub interactables: Vec<flatland_protocol::InteractableView>,
1465 pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1466 pub career: Option<flatland_protocol::PlayerCareerView>,
1467 pub character_sheet_tab: CharacterSheetTab,
1468 pub ledger_period: LedgerPeriod,
1469 pub show_quest_offer: bool,
1470 pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1471 pub show_quest_menu: bool,
1472 pub quest_menu_index: usize,
1473 pub quest_withdraw_confirm: bool,
1474 pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1475 pub show_workers_menu: bool,
1476 pub workers_menu_index: usize,
1477 pub worker_dismiss_confirmation: Option<WorkerDismissConfirmation>,
1478 pub workers_menu_compact: bool,
1480 pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1483 pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1485 pub worker_health_ring_until: BTreeMap<EntityId, Instant>,
1487 pub show_worker_give_picker: bool,
1489 pub worker_give_picker_index: usize,
1490 pub worker_give_picker: Option<WorkerGivePicker>,
1491 pub show_worker_give_target_picker: bool,
1493 pub worker_give_target_picker_index: usize,
1494 pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1495 pub show_worker_take_picker: bool,
1497 pub worker_take_picker_index: usize,
1498 pub worker_take_picker: Option<WorkerTakePicker>,
1499 pub show_worker_teach_picker: bool,
1501 pub worker_teach_picker_index: usize,
1502 pub worker_teach_picker: Option<WorkerTeachPicker>,
1503 pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1505 pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1507 pub attending_worker_instance_id: Option<String>,
1509 pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1511}
1512
1513impl GameState {
1514 pub fn push_log(&mut self, line: impl Into<String>) {
1515 self.logs.push_back(line.into());
1516 while self.logs.len() > MAX_LOG_LINES {
1517 self.logs.pop_front();
1518 }
1519 }
1520
1521 pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1522 self.shop_trade_log.push_back(line.into());
1523 while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1524 self.shop_trade_log.pop_front();
1525 }
1526 }
1527
1528 pub fn clear_shop_trade_log(&mut self) {
1529 self.shop_trade_log.clear();
1530 }
1531
1532 fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1533 if !self.show_shop_menu {
1534 return;
1535 }
1536 let msg = notice.message.trim();
1537 if msg.is_empty() {
1538 return;
1539 }
1540 if notice.coins_delta != 0
1541 || msg.starts_with("Bought ")
1542 || msg.starts_with("Sold ")
1543 || msg.contains("taught you how to craft")
1544 || msg.starts_with("need ")
1545 {
1546 self.push_shop_trade_log(msg);
1547 }
1548 }
1549
1550 pub fn is_alive(&self) -> bool {
1551 self.player
1552 .as_ref()
1553 .and_then(|p| p.vitals)
1554 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1555 .unwrap_or(true)
1556 }
1557
1558 pub fn push_audio(&mut self, cue: crate::social::AudioCue) {
1559 self.social_chat.push_cue(cue);
1560 }
1561
1562 fn sync_gameplay_audio(&mut self) {
1564 use crate::social::AudioCue;
1565 use flatland_protocol::PrimaryAttributes;
1566
1567 let alive = self.is_alive();
1568 let casting = self.cast_progress.is_some();
1569 let telegraph = self.focus_attack_telegraph_active();
1570 let in_aoe = self.player_inside_spatial_telegraph();
1571 let quest_sig = self.quest_audio_signature();
1572 let entity_id = self.entity_id;
1573 let char_level = self
1574 .player
1575 .as_ref()
1576 .and_then(|p| p.attributes)
1577 .map(|a| {
1578 PrimaryAttributes::display(a.strength)
1579 .saturating_add(PrimaryAttributes::display(a.dexterity))
1580 .saturating_add(PrimaryAttributes::display(a.intelligence))
1581 .saturating_add(PrimaryAttributes::display(a.stamina))
1582 .saturating_add(PrimaryAttributes::display(a.vitality))
1583 .saturating_add(PrimaryAttributes::display(a.wisdom))
1584 .saturating_add(PrimaryAttributes::display(a.charisma))
1585 })
1586 .unwrap_or(0);
1587
1588 let fx_ids: Vec<u64> = self.combat_fx.iter().map(|fx| fx.id).collect();
1589 let mut hit_cues = Vec::new();
1590 {
1591 let seen = &self.social_chat.audio_seen_fx_ids;
1592 for fx in &self.combat_fx {
1593 if seen.contains(&fx.id) {
1594 continue;
1595 }
1596 let Some(hit) = fx.hits.iter().find(|h| h.entity_id == entity_id) else {
1597 continue;
1598 };
1599 if hit.outcome == CombatFxHitOutcome::Blocked {
1600 hit_cues.push(AudioCue::CombatBlock);
1601 } else {
1602 let heavy = matches!(
1603 fx.kind,
1604 CombatFxKind::Sphere | CombatFxKind::Cone | CombatFxKind::Beam
1605 );
1606 hit_cues.push(if heavy {
1607 AudioCue::CombatHitHeavy
1608 } else {
1609 AudioCue::CombatHitLight
1610 });
1611 }
1612 }
1613 }
1614
1615 let audio = &mut self.social_chat;
1616 if !audio.audio_bootstrapped {
1617 audio.audio_was_alive = alive;
1618 audio.audio_was_casting = casting;
1619 audio.audio_had_target_telegraph = telegraph;
1620 audio.audio_was_in_aoe = in_aoe;
1621 audio.audio_quest_sig = quest_sig;
1622 audio.audio_char_level = char_level;
1623 audio.audio_seen_fx_ids = fx_ids;
1624 audio.audio_bootstrapped = true;
1625 return;
1626 }
1627
1628 if telegraph && !audio.audio_had_target_telegraph {
1629 audio.push_cue(AudioCue::CombatTelegraphStart);
1630 } else if !telegraph && audio.audio_had_target_telegraph {
1631 audio.push_cue(AudioCue::CombatTelegraphImpact);
1632 }
1633 audio.audio_had_target_telegraph = telegraph;
1634
1635 if in_aoe && !audio.audio_was_in_aoe {
1636 audio.push_cue(AudioCue::CombatAoeWarn);
1637 }
1638 audio.audio_was_in_aoe = in_aoe;
1639
1640 if casting && !audio.audio_was_casting {
1641 audio.push_cue(AudioCue::AbilityCastSelf);
1642 }
1643 audio.audio_was_casting = casting;
1644
1645 if !alive && audio.audio_was_alive {
1646 audio.push_cue(AudioCue::PlayerDeath);
1647 }
1648 audio.audio_was_alive = alive;
1649
1650 if quest_sig != audio.audio_quest_sig && audio.audio_quest_sig != 0 {
1651 audio.push_cue(AudioCue::QuestUpdate);
1652 }
1653 audio.audio_quest_sig = quest_sig;
1654
1655 if char_level > audio.audio_char_level && audio.audio_char_level > 0 {
1656 audio.push_cue(AudioCue::LevelUp);
1657 }
1658 audio.audio_char_level = char_level;
1659
1660 for cue in hit_cues {
1661 audio.push_cue(cue);
1662 }
1663 audio.audio_seen_fx_ids = fx_ids;
1664 }
1665
1666 fn focus_attack_telegraph_active(&self) -> bool {
1667 let Some(tid) = self.combat_target else {
1668 return false;
1669 };
1670 self.entities
1671 .iter()
1672 .find(|e| e.id == tid)
1673 .map(|e| {
1674 e.combat_cues.iter().any(|c| {
1675 matches!(c.kind, CombatCueKind::AttackTelegraph) && c.until_tick > self.tick
1676 })
1677 })
1678 .unwrap_or(false)
1679 }
1680
1681 fn player_inside_spatial_telegraph(&self) -> bool {
1682 let (px, py) = self.player_position();
1683 for e in &self.entities {
1684 for cue in &e.combat_cues {
1685 if !matches!(cue.kind, CombatCueKind::AttackTelegraph)
1686 || cue.until_tick <= self.tick
1687 {
1688 continue;
1689 }
1690 let Some(kind) = cue.telegraph_kind else {
1691 continue;
1692 };
1693 let (ox, oy) = match (cue.origin_x, cue.origin_y) {
1694 (Some(x), Some(y)) => (x, y),
1695 _ => continue,
1696 };
1697 match kind {
1698 CombatFxKind::Sphere => {
1699 let r = cue.radius_m.unwrap_or(1.0);
1700 let dx = px - ox;
1701 let dy = py - oy;
1702 if dx * dx + dy * dy <= r * r {
1703 return true;
1704 }
1705 }
1706 CombatFxKind::Cone | CombatFxKind::MeleeArc => {
1707 let reach = cue.reach_m.unwrap_or(2.0);
1708 let yaw = cue.yaw.unwrap_or(0.0);
1709 let arc = cue.arc_deg.unwrap_or(90.0).to_radians();
1710 let dx = px - ox;
1711 let dy = py - oy;
1712 let dist = (dx * dx + dy * dy).sqrt();
1713 if dist > reach || dist < 0.05 {
1714 continue;
1715 }
1716 let ang = dx.atan2(dy);
1717 let mut delta = ang - yaw;
1718 while delta > std::f32::consts::PI {
1719 delta -= std::f32::consts::TAU;
1720 }
1721 while delta < -std::f32::consts::PI {
1722 delta += std::f32::consts::TAU;
1723 }
1724 if delta.abs() <= arc * 0.5 {
1725 return true;
1726 }
1727 }
1728 _ => {}
1729 }
1730 }
1731 }
1732 false
1733 }
1734
1735 fn quest_audio_signature(&self) -> u64 {
1736 use std::collections::hash_map::DefaultHasher;
1737 use std::hash::{Hash, Hasher};
1738 let mut h = DefaultHasher::new();
1739 for q in &self.quest_log {
1740 q.quest_id.hash(&mut h);
1741 format!("{:?}", q.status).hash(&mut h);
1742 q.current_step_id.hash(&mut h);
1743 for o in &q.objectives {
1744 o.done.hash(&mut h);
1745 o.current.hash(&mut h);
1746 }
1747 }
1748 h.finish()
1749 }
1750
1751 pub fn npc_verb_options(&self) -> Vec<&'static str> {
1753 let Some(ref id) = self.npc_verb_target else {
1754 return vec![];
1755 };
1756 let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1757 return vec!["Talk"];
1758 };
1759 let role = npc.role.as_str();
1760 if Self::npc_role_is_bank(role) {
1761 return vec!["Bank", "Talk"];
1762 }
1763 if Self::npc_role_is_storage(role) {
1764 return vec!["Storage", "Talk"];
1765 }
1766 if Self::npc_role_is_market(role) {
1767 return vec!["Market", "Talk"];
1768 }
1769 if npc.can_trade || Self::npc_role_can_trade(role) {
1770 vec!["Talk", "Trade"]
1771 } else {
1772 vec!["Talk"]
1773 }
1774 }
1775
1776 fn npc_role_can_trade(role: &str) -> bool {
1777 matches!(role, "broker" | "cook" | "farmer" | "merchant")
1778 }
1779
1780 fn npc_role_is_bank(role: &str) -> bool {
1781 role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1782 }
1783
1784 fn npc_role_is_storage(role: &str) -> bool {
1785 role.eq_ignore_ascii_case("storage_manager")
1786 }
1787
1788 fn npc_role_is_market(role: &str) -> bool {
1789 role.eq_ignore_ascii_case("market_clerk")
1790 }
1791
1792 pub fn bank_menu_options(&self) -> Vec<&'static str> {
1793 vec![
1794 "Deposit…",
1795 "Withdraw…",
1796 "Deposit all",
1797 "Withdraw all",
1798 "Transfer…",
1799 ]
1800 }
1801
1802 pub fn storage_menu_options(&self) -> Vec<String> {
1803 let mut opts = vec!["Store…".into(), "Take…".into()];
1804 if let Some(panel) = &self.storage_panel {
1805 for dest in &panel.ship_destinations {
1806 opts.push(format!(
1807 "Ship → {} ({} cp / {} ticks)",
1808 dest.label, dest.fee_copper, dest.travel_ticks
1809 ));
1810 }
1811 }
1812 opts
1813 }
1814
1815 pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1819 let equipped = self.hand_equipped_instance_ids();
1820 self.person_rows()
1821 .into_iter()
1822 .filter(|r| r.depth == 0)
1823 .filter_map(|r| {
1824 let id = r.stack.item_instance_id?;
1825 if equipped.contains(&id) {
1826 return None;
1827 }
1828 Some(StoragePickOption {
1829 item_instance_id: id,
1830 template_id: r.stack.template_id.clone(),
1831 label: storage_stack_label(&r.stack),
1832 quantity: r.stack.quantity,
1833 category: r.stack.category.clone().unwrap_or_default(),
1834 })
1835 })
1836 .collect()
1837 }
1838
1839 pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
1841 let mut ids = std::collections::HashSet::new();
1842 if let Some(id) = self.mainhand_instance_id {
1843 ids.insert(id);
1844 } else if let Some(tid) = &self.mainhand_template_id {
1845 if let Some(id) = self
1846 .inventory_stacks
1847 .iter()
1848 .find(|s| &s.template_id == tid)
1849 .and_then(|s| s.item_instance_id)
1850 {
1851 ids.insert(id);
1852 }
1853 }
1854 if let Some(id) = self.offhand_instance_id {
1855 ids.insert(id);
1856 } else if let Some(tid) = &self.offhand_template_id {
1857 if let Some(id) = self
1858 .inventory_stacks
1859 .iter()
1860 .find(|s| {
1861 &s.template_id == tid
1862 && s.item_instance_id.is_some_and(|iid| !ids.contains(&iid))
1863 })
1864 .and_then(|s| s.item_instance_id)
1865 {
1866 ids.insert(id);
1867 }
1868 }
1869 ids
1870 }
1871
1872 pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1874 let Some(panel) = &self.storage_panel else {
1875 return Vec::new();
1876 };
1877 panel
1878 .contents
1879 .iter()
1880 .filter_map(|s| {
1881 let id = s.item_instance_id?;
1882 Some(StoragePickOption {
1883 item_instance_id: id,
1884 template_id: s.template_id.clone(),
1885 label: storage_stack_label(s),
1886 quantity: s.quantity,
1887 category: s.category.clone().unwrap_or_default(),
1888 })
1889 })
1890 .collect()
1891 }
1892
1893 pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
1895 let mut opts = Vec::new();
1896 if !self
1897 .market_list_item_options(&MarketListSourceKind::Person)
1898 .is_empty()
1899 {
1900 opts.push((MarketListSourceKind::Person, "On person".into()));
1901 }
1902 if let Some(panel) = &self.market_panel {
1903 for vault in &panel.list_vaults {
1904 let source = MarketListSourceKind::TownStorage {
1905 building_id: vault.building_id.clone(),
1906 };
1907 if self.market_list_item_options(&source).is_empty() {
1908 continue;
1909 }
1910 let label = if vault.building_label.is_empty() {
1911 format!("Town storage ({})", vault.building_id)
1912 } else {
1913 format!("Town storage — {}", vault.building_label)
1914 };
1915 opts.push((source, label));
1916 }
1917 }
1918 opts
1919 }
1920
1921 pub fn market_list_item_options(
1923 &self,
1924 source: &MarketListSourceKind,
1925 ) -> Vec<StoragePickOption> {
1926 let filter = self.market_filter.as_str();
1927 let cat_filter = self.market_category_filter;
1928 let mut opts: Vec<StoragePickOption> = match source {
1929 MarketListSourceKind::Person => {
1930 let equipped = self.hand_equipped_instance_ids();
1931 self.person_rows()
1932 .into_iter()
1933 .filter(|r| r.depth == 0)
1934 .filter(|r| self.stack_is_market_listable(&r.stack))
1935 .filter_map(|r| {
1936 let id = r.stack.item_instance_id?;
1937 if equipped.contains(&id) {
1938 return None;
1939 }
1940 Some(StoragePickOption {
1941 item_instance_id: id,
1942 template_id: r.stack.template_id.clone(),
1943 label: storage_stack_label(&r.stack),
1944 quantity: r.stack.quantity,
1945 category: r
1946 .stack
1947 .category
1948 .clone()
1949 .or_else(|| {
1950 self.inventory_item_category(&r.stack.template_id)
1951 .map(str::to_string)
1952 })
1953 .unwrap_or_default(),
1954 })
1955 })
1956 .collect()
1957 }
1958 MarketListSourceKind::TownStorage { building_id } => {
1959 let Some(panel) = &self.market_panel else {
1960 return Vec::new();
1961 };
1962 let Some(vault) = panel
1963 .list_vaults
1964 .iter()
1965 .find(|v| &v.building_id == building_id)
1966 else {
1967 return Vec::new();
1968 };
1969 vault
1970 .contents
1971 .iter()
1972 .filter(|s| self.stack_is_market_listable(s))
1973 .filter_map(|s| {
1974 let id = s.item_instance_id?;
1975 Some(StoragePickOption {
1976 item_instance_id: id,
1977 template_id: s.template_id.clone(),
1978 label: storage_stack_label(s),
1979 quantity: s.quantity,
1980 category: s
1981 .category
1982 .clone()
1983 .or_else(|| {
1984 self.inventory_item_category(&s.template_id)
1985 .map(str::to_string)
1986 })
1987 .unwrap_or_default(),
1988 })
1989 })
1990 .collect()
1991 }
1992 };
1993 opts.retain(|o| {
1994 if !list_label_matches(&o.label, filter) {
1995 return false;
1996 }
1997 if let Some(group) = cat_filter {
1998 inventory_category_group(&o.category).0 == group
1999 } else {
2000 true
2001 }
2002 });
2003 opts
2004 }
2005
2006 pub fn item_base_value_copper_hint(&self, template_id: &str) -> Option<u32> {
2008 if let Some(hint) = self.inventory_hints.get(template_id) {
2009 if let Some(v) = hint.base_value_copper.filter(|v| *v > 0) {
2010 return Some(v);
2011 }
2012 }
2013 if let Some(v) = self
2014 .inventory_stacks
2015 .iter()
2016 .find(|s| s.template_id == template_id)
2017 .and_then(|s| s.base_value_copper.filter(|v| *v > 0))
2018 {
2019 return Some(v);
2020 }
2021 self.market_panel.as_ref().and_then(|panel| {
2022 panel.list_vaults.iter().find_map(|vault| {
2023 vault.contents.iter().find_map(|stack| {
2024 (stack.template_id == template_id)
2025 .then(|| stack.base_value_copper.filter(|v| *v > 0))
2026 .flatten()
2027 })
2028 })
2029 })
2030 }
2031
2032 pub fn npc_market_dump_unit_estimate(&self, template_id: &str) -> Option<u32> {
2034 let base = self.item_base_value_copper_hint(template_id)?;
2035 npc_market_dump_unit_estimate_copper(base)
2036 }
2037
2038 fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
2039 if crate::currency::is_currency(&stack.template_id) {
2040 return false;
2041 }
2042 if let Some(flag) = stack.listable {
2043 return flag;
2044 }
2045 if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
2046 return hint.listable;
2047 }
2048 let cat = stack
2049 .category
2050 .as_deref()
2051 .or_else(|| self.inventory_item_category(&stack.template_id))
2052 .unwrap_or("");
2053 category_default_listable(cat)
2054 }
2055
2056 pub fn market_available_category_groups(&self) -> Vec<&'static str> {
2058 let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
2059 match &self.market_ui_mode {
2060 MarketUiMode::ListPick { source, .. } => {
2061 let raw: Vec<_> = match source {
2062 MarketListSourceKind::Person => self
2063 .person_rows()
2064 .into_iter()
2065 .filter(|r| r.depth == 0)
2066 .filter(|r| self.stack_is_market_listable(&r.stack))
2067 .filter(|r| {
2068 list_label_matches(&storage_stack_label(&r.stack), &self.market_filter)
2069 })
2070 .map(|r| {
2071 r.stack
2072 .category
2073 .clone()
2074 .or_else(|| {
2075 self.inventory_item_category(&r.stack.template_id)
2076 .map(str::to_string)
2077 })
2078 .unwrap_or_default()
2079 })
2080 .collect(),
2081 MarketListSourceKind::TownStorage { building_id } => self
2082 .market_panel
2083 .as_ref()
2084 .and_then(|p| p.list_vaults.iter().find(|v| &v.building_id == building_id))
2085 .map(|vault| {
2086 vault
2087 .contents
2088 .iter()
2089 .filter(|s| self.stack_is_market_listable(s))
2090 .filter(|s| {
2091 list_label_matches(&storage_stack_label(s), &self.market_filter)
2092 })
2093 .map(|s| {
2094 s.category
2095 .clone()
2096 .or_else(|| {
2097 self.inventory_item_category(&s.template_id)
2098 .map(str::to_string)
2099 })
2100 .unwrap_or_default()
2101 })
2102 .collect::<Vec<_>>()
2103 })
2104 .unwrap_or_default(),
2105 };
2106 for category in raw {
2107 let (label, ord) = inventory_category_group(&category);
2108 seen.insert(ord, label);
2109 }
2110 }
2111 _ => {
2112 if let Some(panel) = &self.market_panel {
2113 for listing in &panel.listings {
2114 if !list_label_matches(&listing.display_name, &self.market_filter)
2115 && !list_label_matches(&listing.seller_label, &self.market_filter)
2116 {
2117 continue;
2118 }
2119 let (label, ord) = inventory_category_group(&listing.category);
2120 seen.insert(ord, label);
2121 }
2122 }
2123 }
2124 }
2125 seen.into_values().collect()
2126 }
2127
2128 pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
2130 let Some(panel) = &self.market_panel else {
2131 return Vec::new();
2132 };
2133 let filter = self.market_filter.as_str();
2134 let cat_filter = self.market_category_filter;
2135 panel
2136 .listings
2137 .iter()
2138 .enumerate()
2139 .filter(|(_, listing)| {
2140 if !list_label_matches(&listing.display_name, filter)
2141 && !list_label_matches(&listing.seller_label, filter)
2142 && !list_label_matches(&listing.template_id, filter)
2143 {
2144 return false;
2145 }
2146 if let Some(group) = cat_filter {
2147 inventory_category_group(&listing.category).0 == group
2148 } else {
2149 true
2150 }
2151 })
2152 .map(|(i, _)| i)
2153 .collect()
2154 }
2155
2156 pub fn clear_harvest_state(&mut self) {
2157 self.harvest_in_progress = false;
2158 self.harvest_started_at = None;
2159 }
2160
2161 fn harvest_state_stale(&self) -> bool {
2162 match self.harvest_started_at {
2163 Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
2164 None => self.harvest_in_progress,
2165 }
2166 }
2167
2168 pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
2169 self.player.as_ref().and_then(|p| p.vitals)
2170 }
2171
2172 pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
2173 let materials_ok = blueprint.inputs.iter().all(|input| {
2174 self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
2175 });
2176 let tools_ok = blueprint
2177 .required_tools
2178 .iter()
2179 .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
2180 let station_ok = match blueprint.station.as_deref() {
2181 None | Some("hand") => true,
2182 Some(tag) => self.player_at_station_tag(tag),
2183 };
2184 materials_ok && tools_ok && station_ok
2185 }
2186
2187 pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
2188 if !self.can_craft_blueprint(blueprint) {
2189 return 0;
2190 }
2191 let mut limit = u32::MAX;
2192 for input in &blueprint.inputs {
2193 if input.quantity == 0 {
2194 continue;
2195 }
2196 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2197 limit = limit.min(have / input.quantity);
2198 }
2199 for tool in &blueprint.required_tools {
2200 if tool.consumed {
2201 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2202 limit = limit.min(have);
2203 }
2204 }
2205 let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
2206 if CRAFT_STAMINA_COST > 0.0 {
2207 limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
2208 }
2209 limit
2210 }
2211
2212 pub fn clamp_craft_batch_quantity(&mut self) {
2213 let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
2214 self.craft_batch_quantity = 1;
2215 return;
2216 };
2217 let max = self.max_craft_batches(bp).max(1);
2218 self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
2219 }
2220
2221 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
2222 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
2223 return;
2224 };
2225 let max = self.max_craft_batches(&bp).max(1);
2226 let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
2227 self.craft_batch_quantity = next as u32;
2228 }
2229
2230 pub fn craft_batch_set_max(&mut self) {
2231 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
2232 return;
2233 };
2234 let max = self.max_craft_batches(&bp);
2235 self.craft_batch_quantity = if max == 0 { 1 } else { max };
2236 }
2237
2238 pub fn craft_batch_set_min(&mut self) {
2239 self.craft_batch_quantity = 1;
2240 }
2241
2242 pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
2243 let preserve_ui = self.show_shop_menu;
2244 let tab = self.shop_tab;
2245 let index = self.shop_menu_index;
2246 let qty = self.shop_quantity;
2247
2248 self.show_shop_menu = true;
2249 self.bank_panel = None;
2250 self.show_craft_menu = false;
2251 self.show_inventory_menu = false;
2252 self.show_stats = false;
2253 if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
2254 self.npc_verb_target = Some(catalog.npc_id.clone());
2255 }
2256 self.shop_catalog = Some(catalog);
2257
2258 if preserve_ui {
2259 self.shop_tab = tab;
2260 self.shop_menu_index = index;
2261 self.shop_quantity = qty;
2262 } else {
2263 self.shop_tab = ShopTab::Buy;
2264 self.shop_menu_index = 0;
2265 self.shop_quantity = 1;
2266 self.clear_shop_trade_log();
2267 }
2268 self.show_npc_verb_menu = false;
2269 self.clamp_shop_selection();
2270 }
2271
2272 pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
2273 let same_teller = self
2274 .bank_panel
2275 .as_ref()
2276 .is_some_and(|p| p.npc_id == panel.npc_id);
2277 self.bank_panel = Some(panel);
2278 self.storage_panel = None;
2279 self.market_panel = None;
2280 self.shop_catalog = None;
2281 self.show_shop_menu = false;
2282 self.show_craft_menu = false;
2283 self.show_inventory_menu = false;
2284 self.show_stats = false;
2285 self.show_npc_verb_menu = false;
2286 self.show_npc_chat = false;
2287 self.npc_chat = None;
2288 if !same_teller {
2289 self.bank_menu_index = 0;
2290 self.bank_ui_mode = BankUiMode::Menu;
2291 }
2292 if let Some(panel) = &self.bank_panel {
2293 if self.npc_verb_target.is_none() {
2294 self.npc_verb_target = Some(panel.npc_id.clone());
2295 }
2296 }
2297 }
2298
2299 pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
2300 let same_manager = self
2301 .storage_panel
2302 .as_ref()
2303 .is_some_and(|p| p.npc_id == panel.npc_id);
2304 self.storage_panel = Some(panel);
2305 self.bank_panel = None;
2306 self.market_panel = None;
2307 self.bank_ui_mode = BankUiMode::Menu;
2308 self.shop_catalog = None;
2309 self.show_shop_menu = false;
2310 self.show_craft_menu = false;
2311 self.show_inventory_menu = false;
2312 self.show_stats = false;
2313 self.show_npc_verb_menu = false;
2314 self.show_npc_chat = false;
2315 self.npc_chat = None;
2316 if !same_manager {
2317 self.storage_menu_index = 0;
2318 self.storage_ui_mode = StorageUiMode::Menu;
2319 } else {
2320 self.clamp_storage_pick_index();
2321 }
2322 if let Some(panel) = &self.storage_panel {
2323 if self.npc_verb_target.is_none() {
2324 self.npc_verb_target = Some(panel.npc_id.clone());
2325 }
2326 }
2327 }
2328
2329 pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
2330 for vault in &panel.list_vaults {
2331 self.merge_stack_catalog_hints(&vault.contents);
2332 }
2333 self.market_panel = Some(panel);
2334 self.bank_panel = None;
2335 self.storage_panel = None;
2336 self.shop_catalog = None;
2337 self.show_shop_menu = false;
2338 self.show_craft_menu = false;
2339 self.show_inventory_menu = false;
2340 self.show_stats = false;
2341 self.show_npc_verb_menu = false;
2342 self.show_npc_chat = false;
2343 self.npc_chat = None;
2344 self.market_menu_index = 0;
2345 self.market_buy_confirm = None;
2346 self.market_ui_mode = MarketUiMode::Browse;
2347 self.market_filter.clear();
2348 self.market_filter_focused = false;
2349 self.market_category_filter = None;
2350 if let Some(panel) = &self.market_panel {
2351 if self.npc_verb_target.is_none() {
2352 self.npc_verb_target = Some(panel.npc_id.clone());
2353 }
2354 }
2355 }
2356
2357 pub fn clear_market_panel(&mut self) {
2358 self.market_panel = None;
2359 self.market_menu_index = 0;
2360 self.market_buy_confirm = None;
2361 self.market_ui_mode = MarketUiMode::Browse;
2362 self.market_filter.clear();
2363 self.market_filter_focused = false;
2364 self.market_category_filter = None;
2365 }
2366
2367 pub fn clear_bank_panel(&mut self) {
2368 self.bank_panel = None;
2369 self.bank_menu_index = 0;
2370 self.bank_ui_mode = BankUiMode::Menu;
2371 }
2372
2373 pub fn clear_storage_panel(&mut self) {
2374 self.storage_panel = None;
2375 self.storage_menu_index = 0;
2376 self.storage_ui_mode = StorageUiMode::Menu;
2377 }
2378
2379 fn clamp_storage_pick_index(&mut self) {
2380 match &self.storage_ui_mode {
2381 StorageUiMode::StorePick { index } => {
2382 let n = self.storage_store_options().len();
2383 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2384 self.storage_ui_mode = StorageUiMode::StorePick { index: next };
2385 }
2386 StorageUiMode::TakePick { index } => {
2387 let n = self.storage_vault_options().len();
2388 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2389 self.storage_ui_mode = StorageUiMode::TakePick { index: next };
2390 }
2391 StorageUiMode::ShipPick {
2392 dest_building_id,
2393 dest_label,
2394 index,
2395 } => {
2396 let n = self.storage_vault_options().len();
2397 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2398 self.storage_ui_mode = StorageUiMode::ShipPick {
2399 dest_building_id: dest_building_id.clone(),
2400 dest_label: dest_label.clone(),
2401 index: next,
2402 };
2403 }
2404 StorageUiMode::Menu
2405 | StorageUiMode::StoreAmount { .. }
2406 | StorageUiMode::TakeAmount { .. }
2407 | StorageUiMode::ShipAmount { .. } => {}
2408 }
2409 }
2410
2411 pub fn shop_list_len(&self) -> usize {
2412 let Some(catalog) = &self.shop_catalog else {
2413 return 0;
2414 };
2415 match self.shop_tab {
2416 ShopTab::Buy => catalog.sells.len(),
2417 ShopTab::Sell => catalog.buys.len(),
2418 }
2419 }
2420
2421 pub fn shop_menu_move(&mut self, delta: i32) {
2422 let n = self.shop_list_len();
2423 if n == 0 {
2424 return;
2425 }
2426 let idx = self.shop_menu_index as i32;
2427 let next = (idx + delta).rem_euclid(n as i32);
2428 self.shop_menu_index = next as usize;
2429 self.clamp_shop_quantity();
2430 }
2431
2432 pub fn shop_quantity_adjust(&mut self, delta: i32) {
2433 let max = self.shop_quantity_max();
2434 if max == 0 {
2435 self.shop_quantity = 0;
2436 return;
2437 }
2438 let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
2439 self.shop_quantity = next as u32;
2440 }
2441
2442 pub(crate) fn clamp_shop_selection(&mut self) {
2443 let n = self.shop_list_len();
2444 if n == 0 {
2445 self.shop_menu_index = 0;
2446 } else {
2447 self.shop_menu_index = self.shop_menu_index.min(n - 1);
2448 }
2449 self.clamp_shop_quantity();
2450 }
2451
2452 fn shop_quantity_max(&self) -> u32 {
2453 let Some(catalog) = &self.shop_catalog else {
2454 return 1;
2455 };
2456 match self.shop_tab {
2457 ShopTab::Buy => {
2458 if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
2459 if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
2460 return 1;
2461 }
2462 }
2463 99
2464 }
2465 ShopTab::Sell => catalog
2466 .buys
2467 .get(self.shop_menu_index)
2468 .map(|l| l.quantity)
2469 .unwrap_or(0),
2470 }
2471 }
2472
2473 pub fn shop_quantity_set_max(&mut self) {
2474 self.shop_quantity = self.shop_quantity_max();
2475 }
2476
2477 pub fn shop_quantity_set_min(&mut self) {
2478 let max = self.shop_quantity_max();
2479 self.shop_quantity = if max == 0 { 0 } else { 1 };
2480 }
2481
2482 fn clamp_shop_quantity(&mut self) {
2483 let max = self.shop_quantity_max();
2484 if max == 0 {
2485 self.shop_quantity = 0;
2486 } else {
2487 self.shop_quantity = self.shop_quantity.max(1).min(max);
2488 }
2489 }
2490
2491 pub fn player_at_station_tag(&self, tag: &str) -> bool {
2492 let Some(id) = self.effective_inside_building() else {
2493 return false;
2494 };
2495 self.buildings
2496 .iter()
2497 .find(|b| b.id == id)
2498 .is_some_and(|b| b.tags.iter().any(|t| t == tag))
2499 }
2500
2501 pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
2503 if self.can_craft_blueprint(blueprint) {
2504 return None;
2505 }
2506 let mut missing = Vec::new();
2507 for input in &blueprint.inputs {
2508 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2509 if have < input.quantity {
2510 let name = self.blueprint_ingredient_label(input);
2511 missing.push(format!("{}×{} (have {have})", input.quantity, name));
2512 }
2513 }
2514 for tool in &blueprint.required_tools {
2515 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2516 if have < 1 {
2517 missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
2518 }
2519 }
2520 if let Some(station) = blueprint.station.as_deref() {
2521 if station != "hand" && !self.player_at_station_tag(station) {
2522 missing.push(format!("station: {station} (enter building)"));
2523 }
2524 }
2525 if missing.is_empty() {
2526 None
2527 } else {
2528 Some(missing.join(", "))
2529 }
2530 }
2531
2532 pub fn player_entity(&self) -> Option<&EntityState> {
2533 self.player
2534 .as_ref()
2535 .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
2536 }
2537
2538 pub fn apply_client_ui_prefs(&mut self) {
2540 let cfg = crate::client_config::ClientConfig::load();
2541 if let Some(hidden) = cfg.hud_log_hidden {
2542 self.hud_log_hidden = hidden;
2543 }
2544 if let Some(compact) = cfg.workers_menu_compact {
2545 self.workers_menu_compact = compact;
2546 }
2547 }
2548
2549 pub fn player_position(&self) -> (f32, f32) {
2550 let (x, y, _) = self.player_position_with_z();
2551 (x, y)
2552 }
2553
2554 pub fn player_position_with_z(&self) -> (f32, f32, f32) {
2555 if let Some(p) = self.player_entity() {
2556 (
2557 p.transform.position.x,
2558 p.transform.position.y,
2559 p.transform.position.z,
2560 )
2561 } else {
2562 (0.0, 0.0, 0.0)
2563 }
2564 }
2565
2566 pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
2567 let mut rows: Vec<(String, u32, String)> = self
2568 .inventory
2569 .iter()
2570 .filter(|(_, q)| **q > 0)
2571 .map(|(id, qty)| {
2572 let label = self
2573 .inventory_hints
2574 .get(id)
2575 .map(|h| h.display_name.clone())
2576 .unwrap_or_else(|| id.clone());
2577 (id.clone(), *qty, label)
2578 })
2579 .collect();
2580 rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
2581 rows
2582 }
2583
2584 pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
2585 self.inventory_hints
2586 .get(template_id)
2587 .map(|h| h.category.as_str())
2588 .filter(|c| !c.is_empty())
2589 }
2590
2591 pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
2592 stack
2593 .props
2594 .get("grants_item_status_effect")
2595 .map(|s| !s.is_empty())
2596 .unwrap_or(false)
2597 }
2598
2599 pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
2600 stack
2601 .props
2602 .get("grants_item_status_effect")
2603 .map(String::as_str)
2604 .filter(|s| !s.is_empty())
2605 }
2606
2607 pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
2608 stack
2609 .props
2610 .get("grants_item_status_mode")
2611 .map(String::as_str)
2612 .unwrap_or("on_hit")
2613 }
2614
2615 pub fn grant_target_options(
2617 &self,
2618 grant: &flatland_protocol::ItemStack,
2619 ) -> Vec<GrantTargetOption> {
2620 let mode = Self::grant_mode(grant);
2621 let grant_tags: Vec<&str> = grant
2622 .props
2623 .get("grants_item_status_tags")
2624 .map(|s| {
2625 s.split(',')
2626 .map(str::trim)
2627 .filter(|t| !t.is_empty())
2628 .collect()
2629 })
2630 .unwrap_or_default();
2631 let grant_id = grant.item_instance_id;
2632 let mut out = Vec::new();
2633 let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
2634 let Some(iid) = stack.item_instance_id else {
2635 return;
2636 };
2637 if Some(iid) == grant_id {
2638 return;
2639 }
2640 if stack.props.get("enchantable").map(String::as_str) == Some("0") {
2641 return;
2642 }
2643 if !grant_target_matches_mode(stack, mode) {
2644 return;
2645 }
2646 if !grant_tags_match(stack, &grant_tags) {
2647 return;
2648 }
2649 let name = stack
2650 .display_name
2651 .clone()
2652 .unwrap_or_else(|| stack.template_id.clone());
2653 let bindings = if stack.status_bindings.is_empty() {
2654 String::new()
2655 } else {
2656 format!(
2657 " · {}",
2658 stack
2659 .status_bindings
2660 .iter()
2661 .map(|b| b.effect_id.as_str())
2662 .collect::<Vec<_>>()
2663 .join(", ")
2664 )
2665 };
2666 out.push(GrantTargetOption {
2667 label: format!("{where_label}: {name}{bindings}"),
2668 target_instance_id: iid,
2669 });
2670 };
2671 fn walk(
2672 stacks: &[flatland_protocol::ItemStack],
2673 where_label: &str,
2674 push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
2675 ) {
2676 for s in stacks {
2677 push(s, where_label);
2678 if !s.contents.is_empty() {
2679 let nested = format!(
2680 "{where_label}/{}",
2681 s.display_name.as_deref().unwrap_or(s.template_id.as_str())
2682 );
2683 walk(&s.contents, &nested, push);
2684 }
2685 }
2686 }
2687 walk(&self.inventory_stacks, "Bag", &mut push);
2688 for (slot, stack) in &self.worn {
2689 push(stack, body_slot_label(*slot));
2690 let nest = format!(
2691 "{}/{}",
2692 body_slot_label(*slot),
2693 stack
2694 .display_name
2695 .as_deref()
2696 .unwrap_or(stack.template_id.as_str())
2697 );
2698 walk(&stack.contents, &nest, &mut push);
2699 }
2700 out
2701 }
2702
2703 pub fn item_base_mass(&self, template_id: &str) -> f32 {
2704 self.inventory_hints
2705 .get(template_id)
2706 .and_then(|h| h.base_mass)
2707 .unwrap_or(0.5)
2708 }
2709
2710 pub fn item_base_volume(&self, template_id: &str) -> f32 {
2711 self.inventory_hints
2712 .get(template_id)
2713 .and_then(|h| h.base_volume)
2714 .unwrap_or(1.0)
2715 }
2716
2717 pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
2718 let unit = stack
2719 .base_mass
2720 .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
2721 unit * stack.quantity as f32
2722 }
2723
2724 fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
2725 let unit = stack.base_volume.unwrap_or(1.0);
2726 unit * stack.quantity as f32
2727 + stack
2728 .contents
2729 .iter()
2730 .map(Self::stack_tree_volume)
2731 .sum::<f32>()
2732 }
2733
2734 fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
2735 contents.iter().map(Self::stack_tree_volume).sum()
2736 }
2737
2738 fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
2739 self.inventory_hints
2740 .get(template_id)
2741 .and_then(|h| h.capacity_volume)
2742 .filter(|c| *c > 0.0)
2743 }
2744
2745 fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
2746 stack
2747 .capacity_volume
2748 .filter(|c| *c > 0.0)
2749 .or_else(|| self.template_capacity_volume(&stack.template_id))
2750 }
2751
2752 pub fn container_volume_label(&self, row: &InventoryRow) -> String {
2754 let Some((used, cap)) = self.container_volume_stats(row) else {
2755 return String::new();
2756 };
2757 let free = (cap - used).max(0.0);
2758 format!(" vol {used:.0}/{cap:.0} ({free:.0} free)")
2759 }
2760
2761 fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
2762 if row.is_chest_shell {
2763 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
2764 return None;
2765 };
2766 let chest = self
2767 .placed_containers
2768 .iter()
2769 .find(|c| c.id == *container_id)?;
2770 let cap = self
2771 .stack_capacity_volume(&row.stack)
2772 .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
2773 let used = if chest.accessible {
2774 Self::contents_used_volume(&chest.contents)
2775 } else {
2776 0.0
2777 };
2778 return Some((used, cap));
2779 }
2780
2781 let cap = self.stack_capacity_volume(&row.stack)?;
2782 let used = Self::contents_used_volume(&row.stack.contents);
2783 Some((used, cap))
2784 }
2785
2786 pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
2787 if row.is_chest_shell {
2788 return true;
2789 }
2790 if row.is_equip_shell {
2791 return self.inventory_item_category(&row.stack.template_id) == Some("container");
2792 }
2793 self.inventory_item_category(&row.stack.template_id) == Some("container")
2794 || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
2795 }
2796
2797 fn container_stack_for(
2798 &self,
2799 location: &flatland_protocol::InventoryLocation,
2800 parent_instance_id: Option<uuid::Uuid>,
2801 ) -> Option<flatland_protocol::ItemStack> {
2802 match location {
2803 flatland_protocol::InventoryLocation::Root => {
2804 let pid = parent_instance_id?;
2805 self.find_stack_by_instance(&self.inventory_stacks, pid)
2806 }
2807 flatland_protocol::InventoryLocation::Worn { slot } => {
2808 let worn = self.worn.get(slot)?;
2809 if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
2810 Some(worn.clone())
2811 } else {
2812 self.find_stack_by_instance(&worn.contents, parent_instance_id?)
2813 }
2814 }
2815 flatland_protocol::InventoryLocation::Placed { container_id } => {
2816 let chest = self
2817 .placed_containers
2818 .iter()
2819 .find(|c| c.id == *container_id)?;
2820 if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
2821 Some(flatland_protocol::ItemStack {
2822 template_id: chest.template_id.clone(),
2823 quantity: 1,
2824 item_instance_id: chest.item_instance_id,
2825 props: Default::default(),
2826 status_bindings: Vec::new(),
2827 contents: chest.contents.clone(),
2828 display_name: Some(chest.display_name.clone()),
2829 category: Some("container".into()),
2830 capacity_volume: self
2831 .inventory_hints
2832 .get(&chest.template_id)
2833 .and_then(|h| h.capacity_volume),
2834 worker_lodging_capacity: chest.worker_lodging_capacity,
2835 ..Default::default()
2836 })
2837 } else {
2838 self.find_stack_by_instance(&chest.contents, parent_instance_id?)
2839 }
2840 }
2841 flatland_protocol::InventoryLocation::Keychain => None,
2842 flatland_protocol::InventoryLocation::WhisperPouch => None,
2843 }
2844 }
2845
2846 fn find_stack_by_instance(
2847 &self,
2848 stacks: &[flatland_protocol::ItemStack],
2849 instance_id: uuid::Uuid,
2850 ) -> Option<flatland_protocol::ItemStack> {
2851 for stack in stacks {
2852 if stack.item_instance_id == Some(instance_id) {
2853 return Some(stack.clone());
2854 }
2855 if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
2856 return Some(found);
2857 }
2858 }
2859 None
2860 }
2861
2862 pub fn max_movable_to(
2864 &self,
2865 template_id: &str,
2866 stack_qty: u32,
2867 from: &flatland_protocol::InventoryLocation,
2868 to: &flatland_protocol::InventoryLocation,
2869 parent_instance_id: Option<uuid::Uuid>,
2870 ) -> u32 {
2871 let unit_vol = self.item_base_volume(template_id);
2872 let unit_mass = self.item_base_mass(template_id);
2873 let mut limit = stack_qty;
2874
2875 if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2876 let cap = parent
2877 .capacity_volume
2878 .or_else(|| {
2879 self.inventory_hints
2880 .get(&parent.template_id)
2881 .and_then(|h| h.capacity_volume)
2882 })
2883 .unwrap_or(0.0);
2884 if cap > 0.0 && unit_vol > 0.0 {
2885 let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2886 limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2887 }
2888 }
2889
2890 let to_person = matches!(
2891 to,
2892 flatland_protocol::InventoryLocation::Root
2893 | flatland_protocol::InventoryLocation::Worn { .. }
2894 );
2895 let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2896 if to_person && from_placed && unit_mass > 0.0 {
2897 let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2898 if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2899 limit = 0;
2900 } else {
2901 limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2902 }
2903 }
2904
2905 limit.max(0).min(stack_qty)
2906 }
2907
2908 pub fn move_picker_max_at_selection(&self) -> u32 {
2909 let Some(picker) = &self.move_picker else {
2910 return 1;
2911 };
2912 let Some(opt) = picker.options.get(self.move_picker_index) else {
2913 return picker.stack_quantity;
2914 };
2915 match &opt.kind {
2916 MoveOptionKind::Cancel
2917 | MoveOptionKind::Drop
2918 | MoveOptionKind::Use
2919 | MoveOptionKind::GrantApply
2920 | MoveOptionKind::SellPlotToCrown { .. }
2921 | MoveOptionKind::PickupPlaced { .. }
2922 | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
2923 MoveOptionKind::Move {
2924 location,
2925 parent_instance_id,
2926 } => self.max_movable_to(
2927 &picker.template_id,
2928 picker.stack_quantity,
2929 &picker.from,
2930 location,
2931 *parent_instance_id,
2932 ),
2933 }
2934 }
2935
2936 pub fn clamp_move_picker_quantity(&mut self) {
2937 let max = self.move_picker_max_at_selection();
2938 if let Some(picker) = &mut self.move_picker {
2939 if max == 0 {
2940 picker.quantity = 1;
2941 } else {
2942 picker.quantity = picker.quantity.clamp(1, max);
2943 }
2944 }
2945 }
2946
2947 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2948 let max = self.move_picker_max_at_selection().max(1);
2949 if let Some(picker) = &mut self.move_picker {
2950 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2951 picker.quantity = next as u32;
2952 }
2953 }
2954
2955 pub fn move_picker_set_quantity_max(&mut self) {
2956 let max = self.move_picker_max_at_selection();
2957 if let Some(picker) = &mut self.move_picker {
2958 picker.quantity = if max == 0 {
2959 1
2960 } else {
2961 max.min(picker.stack_quantity)
2962 };
2963 }
2964 }
2965
2966 pub fn move_picker_set_quantity_min(&mut self) {
2967 if let Some(picker) = &mut self.move_picker {
2968 picker.quantity = 1;
2969 }
2970 }
2971
2972 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2973 if let Some(picker) = &mut self.destroy_picker {
2974 let max = picker.stack_quantity.max(1);
2975 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2976 picker.quantity = next as u32;
2977 }
2978 }
2979
2980 pub fn destroy_picker_set_quantity_max(&mut self) {
2981 if let Some(picker) = &mut self.destroy_picker {
2982 picker.quantity = picker.stack_quantity.max(1);
2983 }
2984 }
2985
2986 pub fn destroy_picker_set_quantity_min(&mut self) {
2987 if let Some(picker) = &mut self.destroy_picker {
2988 picker.quantity = 1;
2989 }
2990 }
2991
2992 pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2993 let have = self.inventory.get(template_id).copied().unwrap_or(0);
2994 (have, have >= need)
2995 }
2996
2997 pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2999 let have = self
3000 .plot_build_offer
3001 .as_ref()
3002 .and_then(|o| {
3003 o.available
3004 .iter()
3005 .find(|s| s.template_id == template_id)
3006 .map(|s| s.quantity)
3007 })
3008 .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
3009 (have, have >= need)
3010 }
3011
3012 pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3013 self.building_materials
3014 .iter()
3015 .filter(|m| m.can_wall)
3016 .collect()
3017 }
3018
3019 pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3020 self.building_materials
3021 .iter()
3022 .filter(|m| m.can_roof)
3023 .collect()
3024 }
3025
3026 pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3027 self.plot_build_wall_options()
3028 .get(self.plot_build_wall_index)
3029 .copied()
3030 }
3031
3032 pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3033 self.plot_build_roof_options()
3034 .get(self.plot_build_roof_index)
3035 .copied()
3036 }
3037
3038 pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
3040 let Some(wall) = self.plot_build_selected_wall() else {
3041 return Vec::new();
3042 };
3043 let Some(roof) = self.plot_build_selected_roof() else {
3044 return Vec::new();
3045 };
3046 let area = self
3047 .plot_build_offer
3048 .as_ref()
3049 .filter(|o| o.pad_ok)
3050 .map(|o| o.pad_width_m * o.pad_depth_m)
3051 .unwrap_or(0.0);
3052 if area <= 0.0 {
3053 return Vec::new();
3054 }
3055 let mut map: std::collections::HashMap<String, (String, u32)> =
3056 std::collections::HashMap::new();
3057 for line in &wall.wall_bom {
3058 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3059 if qty == 0 {
3060 continue;
3061 }
3062 let name = if line.display_name.is_empty() {
3063 line.template_id.clone()
3064 } else {
3065 line.display_name.clone()
3066 };
3067 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3068 entry.1 = entry.1.saturating_add(qty);
3069 }
3070 for line in &roof.roof_bom {
3071 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3072 if qty == 0 {
3073 continue;
3074 }
3075 let name = if line.display_name.is_empty() {
3076 line.template_id.clone()
3077 } else {
3078 line.display_name.clone()
3079 };
3080 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3081 entry.1 = entry.1.saturating_add(qty);
3082 }
3083 let mut out: Vec<_> = map
3084 .into_iter()
3085 .map(|(id, (name, qty))| (id, name, qty))
3086 .collect();
3087 out.sort_by(|a, b| a.0.cmp(&b.0));
3088 out
3089 }
3090
3091 pub fn plot_build_duration_secs(&self) -> Option<f32> {
3092 let wall = self.plot_build_selected_wall()?;
3093 let roof = self.plot_build_selected_roof()?;
3094 let offer = self.plot_build_offer.as_ref()?;
3095 if !offer.pad_ok {
3096 return None;
3097 }
3098 let area = offer.pad_width_m * offer.pad_depth_m;
3099 let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
3100 let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
3101 Some(ticks.max(2.0) / 30.0)
3102 }
3103
3104 pub fn plot_build_can_afford(&self) -> bool {
3105 if self.plot_build_offer.as_ref().is_none_or(|o| !o.pad_ok) {
3106 return false;
3107 }
3108 self.plot_build_bom_lines()
3109 .iter()
3110 .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
3111 }
3112
3113 pub fn currency_display(&self) -> String {
3114 crate::currency::currency_line(&self.inventory)
3115 }
3116
3117 pub fn in_shallow_water(&self) -> bool {
3119 let (px, py) = self.player_position();
3120 self.terrain_at(px, py)
3121 .is_some_and(|k| k == TerrainKindView::ShallowWater)
3122 }
3123
3124 pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
3125 self.terrain_zone_at(x, y).map(|z| z.kind)
3126 }
3127
3128 pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
3130 use std::cell::RefCell;
3131
3132 const CHUNK: i32 = 8;
3133 thread_local! {
3134 static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
3135 RefCell::new(None);
3136 }
3137
3138 let zones = &self.terrain_zones;
3139 if zones.is_empty() {
3140 return None;
3141 }
3142 if zones.len() <= 48 {
3143 return zones
3144 .iter()
3145 .enumerate()
3146 .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
3147 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
3148 .map(|(_, z)| z);
3149 }
3150
3151 let ptr = zones.as_ptr();
3152 let len = zones.len();
3153 INDEX.with(|cell| {
3154 let mut slot = cell.borrow_mut();
3155 let stale = match slot.as_ref() {
3156 Some((p, l, _)) => *p != ptr || *l != len,
3157 None => true,
3158 };
3159 if stale {
3160 let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
3161 std::collections::HashMap::new();
3162 for (zi, z) in zones.iter().enumerate() {
3163 let x0 = z.x0.min(z.x1).floor() as i32;
3164 let y0 = z.y0.min(z.y1).floor() as i32;
3165 let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
3166 let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
3167 let cx0 = x0.div_euclid(CHUNK);
3168 let cy0 = y0.div_euclid(CHUNK);
3169 let cx1 = x1.div_euclid(CHUNK);
3170 let cy1 = y1.div_euclid(CHUNK);
3171 for cy in cy0..=cy1 {
3172 for cx in cx0..=cx1 {
3173 chunks.entry((cx, cy)).or_default().push(zi);
3174 }
3175 }
3176 }
3177 *slot = Some((ptr, len, chunks));
3178 }
3179 let chunks = &slot.as_ref().expect("index").2;
3180 let cx = (x.floor() as i32).div_euclid(CHUNK);
3181 let cy = (y.floor() as i32).div_euclid(CHUNK);
3182 let mut best: Option<(usize, &TerrainZoneView)> = None;
3183 if let Some(list) = chunks.get(&(cx, cy)) {
3184 for &zi in list {
3185 let Some(z) = zones.get(zi) else { continue };
3186 if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
3187 continue;
3188 }
3189 best = match best {
3190 None => Some((zi, z)),
3191 Some((bi, bz)) => {
3192 if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
3193 Some((zi, z))
3194 } else {
3195 Some((bi, bz))
3196 }
3197 }
3198 };
3199 }
3200 }
3201 best.map(|(_, z)| z)
3202 })
3203 }
3204
3205 pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
3207 self.terrain_zone_at(x, y)
3208 .map(|z| z.elevation)
3209 .unwrap_or(0.0)
3210 }
3211
3212 pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
3214 const TOL: f32 = 0.35;
3215 let mut levels = vec![self.elevation_at(x, y)];
3216 for p in &self.z_platforms {
3217 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
3218 levels.push(p.z);
3219 }
3220 }
3221 levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
3222 levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
3223 levels
3224 }
3225
3226 pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
3227 const TOL: f32 = 0.35;
3228 self.walkable_levels_at(x, y)
3229 .iter()
3230 .any(|&l| (l - z).abs() <= TOL)
3231 }
3232
3233 pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
3234 let mut top = self.elevation_at(x, y);
3235 for p in &self.z_platforms {
3236 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
3237 top = top.max(p.z);
3238 }
3239 }
3240 top
3241 }
3242
3243 pub fn effective_inside_building(&self) -> Option<String> {
3245 self.player_entity().and_then(|p| p.inside_building.clone())
3246 }
3247
3248 pub fn placed_container_in_current_space(
3252 &self,
3253 c: &flatland_protocol::PlacedContainerView,
3254 ) -> bool {
3255 match (
3256 self.effective_inside_building().as_deref(),
3257 c.building_id.as_deref(),
3258 ) {
3259 (None, None) => true,
3260 (Some(a), Some(b)) => a == b,
3261 _ => false,
3262 }
3263 }
3264
3265 fn merge_stack_catalog_hints(&mut self, stacks: &[flatland_protocol::ItemStack]) {
3266 fn walk(
3267 stacks: &[flatland_protocol::ItemStack],
3268 hints: &mut std::collections::HashMap<String, InventoryHint>,
3269 ) {
3270 for stack in stacks {
3271 if stack.display_name.is_some()
3272 || stack.category.is_some()
3273 || stack.base_mass.is_some()
3274 || stack.base_volume.is_some()
3275 || stack.base_value_copper.is_some()
3276 {
3277 hints.insert(
3278 stack.template_id.clone(),
3279 InventoryHint {
3280 display_name: stack
3281 .display_name
3282 .clone()
3283 .unwrap_or_else(|| stack.template_id.clone()),
3284 category: stack.category.clone().unwrap_or_default(),
3285 base_mass: stack.base_mass,
3286 base_volume: stack.base_volume,
3287 capacity_volume: stack.capacity_volume,
3288 stackable: stack.stackable.unwrap_or(true),
3289 listable: stack.listable.unwrap_or_else(|| {
3290 category_default_listable(stack.category.as_deref().unwrap_or(""))
3291 }),
3292 base_value_copper: stack.base_value_copper,
3293 },
3294 );
3295 }
3296 walk(&stack.contents, hints);
3297 }
3298 }
3299 walk(stacks, &mut self.inventory_hints);
3300 }
3301
3302 pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
3303 self.inventory_stacks = stacks.to_vec();
3304 self.inventory.clear();
3305 self.inventory_hints.clear();
3306 fn walk(
3307 stacks: &[flatland_protocol::ItemStack],
3308 inventory: &mut std::collections::HashMap<String, u32>,
3309 hints: &mut std::collections::HashMap<String, InventoryHint>,
3310 ) {
3311 for stack in stacks {
3312 *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
3313 if stack.display_name.is_some()
3314 || stack.category.is_some()
3315 || stack.base_mass.is_some()
3316 || stack.base_volume.is_some()
3317 || stack.base_value_copper.is_some()
3318 {
3319 hints.insert(
3320 stack.template_id.clone(),
3321 InventoryHint {
3322 display_name: stack
3323 .display_name
3324 .clone()
3325 .unwrap_or_else(|| stack.template_id.clone()),
3326 category: stack.category.clone().unwrap_or_default(),
3327 base_mass: stack.base_mass,
3328 base_volume: stack.base_volume,
3329 capacity_volume: stack.capacity_volume,
3330 stackable: stack.stackable.unwrap_or(true),
3331 listable: stack.listable.unwrap_or_else(|| {
3332 category_default_listable(stack.category.as_deref().unwrap_or(""))
3333 }),
3334 base_value_copper: stack.base_value_copper,
3335 },
3336 );
3337 }
3338 walk(&stack.contents, inventory, hints);
3339 }
3340 }
3341 walk(stacks, &mut self.inventory, &mut self.inventory_hints);
3342 for item in self.worn.values() {
3344 walk(
3345 std::slice::from_ref(item),
3346 &mut self.inventory,
3347 &mut self.inventory_hints,
3348 );
3349 }
3350 }
3351
3352 pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
3355 let subtract_items =
3356 notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
3357 for stack in ¬ice.inventory_delta {
3358 if stack.quantity == 0 {
3359 continue;
3360 }
3361 if subtract_items {
3362 crate::currency::drain_template_stacks(
3363 &mut self.inventory_stacks,
3364 &stack.template_id,
3365 stack.quantity,
3366 );
3367 continue;
3368 }
3369 let stackable = self
3370 .inventory_hints
3371 .get(&stack.template_id)
3372 .map(|h| h.stackable)
3373 .or(stack.stackable)
3374 .unwrap_or(true);
3375 if stackable {
3376 if let Some(existing) = self
3377 .inventory_stacks
3378 .iter_mut()
3379 .find(|s| s.template_id == stack.template_id)
3380 {
3381 existing.quantity = existing.quantity.saturating_add(stack.quantity);
3382 if stack.display_name.is_some() {
3383 existing.display_name = stack.display_name.clone();
3384 }
3385 if stack.category.is_some() {
3386 existing.category = stack.category.clone();
3387 }
3388 continue;
3389 }
3390 }
3391 self.inventory_stacks.push(stack.clone());
3392 }
3393 if notice.coins_delta != 0 {
3394 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
3395 }
3396 if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
3397 let stacks = self.inventory_stacks.clone();
3398 self.sync_inventory_from_stacks(&stacks);
3399 }
3400 self.record_shop_trade_notice(notice);
3401 }
3402
3403 pub fn worn_rows(&self) -> Vec<InventoryRow> {
3408 let mut rows = Vec::new();
3409 for (slot, item) in &self.worn {
3410 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3411 rows.push(InventoryRow {
3412 depth: 0,
3413 stack: item.clone(),
3414 from: from.clone(),
3415 from_parent_instance_id: None,
3416 is_equip_shell: true,
3417 is_chest_shell: false,
3418 section: InventorySection::Worn,
3419 });
3420 for child in &item.contents {
3421 push_inventory_rows(
3422 &mut rows,
3423 1,
3424 child,
3425 &from,
3426 item.item_instance_id,
3427 InventorySection::Worn,
3428 );
3429 }
3430 }
3431 rows
3432 }
3433
3434 pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
3436 let equipped = self.hand_equipped_instance_ids();
3437 self.inventory_stacks
3438 .iter()
3439 .filter(|s| s.item_instance_id.is_some_and(|id| !equipped.contains(&id)))
3440 .collect()
3441 }
3442
3443 pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
3445 let equipped = self.hand_equipped_instance_ids();
3446 self.inventory_stacks
3447 .iter()
3448 .filter_map(|stack| {
3449 let item_instance_id = stack.item_instance_id?;
3450 if equipped.contains(&item_instance_id) {
3451 return None;
3452 }
3453 let label = stack
3454 .display_name
3455 .clone()
3456 .unwrap_or_else(|| stack.template_id.clone());
3457 let label = if stack.quantity > 1 {
3458 format!("{label} ×{}", stack.quantity)
3459 } else {
3460 label
3461 };
3462 Some(WorkerGiveOption {
3463 item_instance_id,
3464 label,
3465 quantity: stack.quantity,
3466 template_id: stack.template_id.clone(),
3467 })
3468 })
3469 .collect()
3470 }
3471
3472 pub fn teachable_blueprint_options(
3474 &self,
3475 worker: &flatland_protocol::HiredWorkerView,
3476 ) -> Vec<WorkerTeachOption> {
3477 let copper = crate::currency::copper_from_counts(&self.inventory);
3478 let mut options: Vec<WorkerTeachOption> = self
3479 .blueprints
3480 .iter()
3481 .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
3482 .map(|bp| {
3483 let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
3484 let cost = bp.worker_train_copper;
3485 WorkerTeachOption {
3486 blueprint_id: bp.id.clone(),
3487 label: if bp.label.is_empty() {
3488 bp.id.clone()
3489 } else {
3490 bp.label.clone()
3491 },
3492 cost_copper: cost,
3493 min_level,
3494 worker_level: worker.level,
3495 can_afford: copper >= cost,
3496 level_ok: worker.level >= min_level,
3497 }
3498 })
3499 .collect();
3500 options.sort_by(|a, b| a.label.cmp(&b.label));
3501 options
3502 }
3503
3504 pub fn person_rows(&self) -> Vec<InventoryRow> {
3507 self.person_rows_filtered("")
3508 }
3509
3510 pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3511 let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
3512 roots.sort_by(|a, b| {
3513 let ca = a
3514 .category
3515 .as_deref()
3516 .or_else(|| self.inventory_item_category(&a.template_id))
3517 .unwrap_or("");
3518 let cb = b
3519 .category
3520 .as_deref()
3521 .or_else(|| self.inventory_item_category(&b.template_id))
3522 .unwrap_or("");
3523 let ga = inventory_category_group(ca).1;
3524 let gb = inventory_category_group(cb).1;
3525 ga.cmp(&gb).then_with(|| {
3526 let na = a.display_name.as_deref().unwrap_or(a.template_id.as_str());
3527 let nb = b.display_name.as_deref().unwrap_or(b.template_id.as_str());
3528 na.cmp(nb)
3529 })
3530 });
3531 let mut rows = Vec::new();
3532 for stack in roots {
3533 push_inventory_rows_filtered(
3534 &mut rows,
3535 0,
3536 stack,
3537 &flatland_protocol::InventoryLocation::Root,
3538 None,
3539 InventorySection::Person,
3540 filter,
3541 );
3542 }
3543 rows
3544 }
3545
3546 pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3547 if filter.is_empty() {
3548 return self.worn_rows();
3549 }
3550 let mut rows = Vec::new();
3551 for (slot, item) in &self.worn {
3552 if !stack_matches_filter(item, filter) {
3553 continue;
3554 }
3555 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3556 let self_hit = {
3557 let f = filter.to_ascii_lowercase();
3558 let name = item
3559 .display_name
3560 .as_deref()
3561 .unwrap_or("")
3562 .to_ascii_lowercase();
3563 let tid = item.template_id.to_ascii_lowercase();
3564 name.contains(&f) || tid.contains(&f)
3565 };
3566 rows.push(InventoryRow {
3567 depth: 0,
3568 stack: item.clone(),
3569 from: from.clone(),
3570 from_parent_instance_id: None,
3571 is_equip_shell: true,
3572 is_chest_shell: false,
3573 section: InventorySection::Worn,
3574 });
3575 for child in &item.contents {
3576 if self_hit || stack_matches_filter(child, filter) {
3577 push_inventory_rows_filtered(
3578 &mut rows,
3579 1,
3580 child,
3581 &from,
3582 item.item_instance_id,
3583 InventorySection::Worn,
3584 if self_hit { "" } else { filter },
3585 );
3586 }
3587 }
3588 }
3589 rows
3590 }
3591
3592 pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
3594 let mut rows = self.worn_rows();
3595 rows.extend(self.person_rows());
3596 rows.into_iter().map(|r| (r.depth, r.stack)).collect()
3597 }
3598
3599 pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
3603 let (px, py) = self.player_position();
3604 let mut list: Vec<NearbyContainer> = self
3605 .placed_containers
3606 .iter()
3607 .filter(|c| self.placed_container_in_current_space(c))
3608 .filter_map(|c| {
3609 let distance_m = (c.x - px).hypot(c.y - py);
3610 if distance_m > CONTAINER_RANGE_M {
3611 return None;
3612 }
3613 let mut rows = Vec::new();
3614 let from = flatland_protocol::InventoryLocation::Placed {
3615 container_id: c.id.clone(),
3616 };
3617 rows.push(InventoryRow {
3618 depth: 0,
3619 stack: flatland_protocol::ItemStack {
3620 template_id: c.template_id.clone(),
3621 quantity: 1,
3622 item_instance_id: c.item_instance_id,
3623 props: Default::default(),
3624 status_bindings: Vec::new(),
3625 contents: Vec::new(),
3626 display_name: Some(c.display_name.clone()),
3627 category: Some("container".into()),
3628 capacity_volume: c.capacity_volume,
3629 worker_lodging_capacity: c.worker_lodging_capacity,
3630 ..Default::default()
3631 },
3632 from: from.clone(),
3633 from_parent_instance_id: None,
3634 is_equip_shell: false,
3635 is_chest_shell: true,
3636 section: InventorySection::Nearby,
3637 });
3638 if c.accessible {
3639 for child in &c.contents {
3640 push_inventory_rows(
3641 &mut rows,
3642 1,
3643 child,
3644 &from,
3645 c.item_instance_id,
3646 InventorySection::Nearby,
3647 );
3648 }
3649 }
3650 Some(NearbyContainer {
3651 view: c.clone(),
3652 distance_m,
3653 rows,
3654 })
3655 })
3656 .collect();
3657 list.sort_by(|a, b| {
3658 a.distance_m
3659 .partial_cmp(&b.distance_m)
3660 .unwrap_or(std::cmp::Ordering::Equal)
3661 });
3662 list
3663 }
3664
3665 pub fn nearest_placed_container(
3667 &self,
3668 max_dist: f32,
3669 ) -> Option<flatland_protocol::PlacedContainerView> {
3670 let (px, py) = self.player_position();
3671 self.placed_containers
3672 .iter()
3673 .filter(|c| self.placed_container_in_current_space(c))
3674 .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
3675 .min_by(|a, b| {
3676 let da = (a.x - px).hypot(a.y - py);
3677 let db = (b.x - px).hypot(b.y - py);
3678 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3679 })
3680 .cloned()
3681 }
3682
3683 pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
3686 let filter = self.inventory_filter.as_str();
3687 match self.inventory_tab {
3688 InventoryTab::OnPerson => {
3689 let mut rows = self.worn_rows_filtered(filter);
3690 rows.extend(self.person_rows_filtered(filter));
3691 rows
3692 }
3693 InventoryTab::Nearby => {
3694 let mut rows = Vec::new();
3695 for nc in self.nearby_containers() {
3696 if filter.is_empty() {
3697 rows.extend(nc.rows);
3698 continue;
3699 }
3700 let shell = nc.rows.first().cloned();
3701 let contents: Vec<_> = nc
3702 .rows
3703 .iter()
3704 .skip(1)
3705 .filter(|r| stack_matches_filter(&r.stack, filter))
3706 .cloned()
3707 .collect();
3708 let shell_hit = shell
3709 .as_ref()
3710 .map(|s| stack_matches_filter(&s.stack, filter))
3711 .unwrap_or(false);
3712 if shell_hit || !contents.is_empty() {
3713 if let Some(s) = shell {
3714 rows.push(s);
3715 }
3716 if shell_hit {
3717 rows.extend(nc.rows.into_iter().skip(1));
3718 } else {
3719 rows.extend(contents);
3720 }
3721 }
3722 }
3723 rows
3724 }
3725 }
3726 }
3727
3728 pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
3729 self.inventory_selectable_rows()
3730 .into_iter()
3731 .nth(self.inventory_menu_index)
3732 }
3733
3734 fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
3735 let cat = self
3736 .inventory_item_category(&row.stack.template_id)
3737 .unwrap_or("");
3738 if cat == "key" {
3739 self.key_inventory_label(&row.stack)
3740 } else {
3741 row.stack
3742 .display_name
3743 .clone()
3744 .unwrap_or_else(|| row.stack.template_id.clone())
3745 }
3746 }
3747
3748 fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
3750 let bindings =
3751 format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
3752 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3753 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3754 let mode = Self::grant_mode(&row.stack);
3755 format!(" [grant {effect} · {mode} — e apply]")
3756 } else {
3757 String::new()
3758 };
3759 let qty = if row.stack.quantity > 1 {
3760 format!(" ×{}", row.stack.quantity)
3761 } else {
3762 String::new()
3763 };
3764 let worn_slot = if row.is_equip_shell {
3765 match row.from {
3766 flatland_protocol::InventoryLocation::Worn { slot } => {
3767 format!(" ({})", body_slot_label(slot))
3768 }
3769 _ => String::new(),
3770 }
3771 } else {
3772 String::new()
3773 };
3774 format!("{grant_hint}{bindings}{qty}{worn_slot}")
3775 }
3776
3777 fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
3778 (
3779 row.stack.template_id.clone(),
3780 self.inventory_row_base_label(row),
3781 self.inventory_row_visible_mod_signature(row),
3782 )
3783 }
3784
3785 fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
3787 let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
3788 for row in self.inventory_selectable_rows() {
3789 if row.stack.item_instance_id.is_none() {
3790 continue;
3791 }
3792 let key = self.inventory_row_instance_identity_key(&row);
3793 *counts.entry(key).or_default() += 1;
3794 }
3795 counts
3796 .into_iter()
3797 .filter(|(_, n)| *n > 1)
3798 .map(|(k, _)| k)
3799 .collect()
3800 }
3801
3802 fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
3803 let hex: String = id
3804 .as_simple()
3805 .to_string()
3806 .chars()
3807 .filter(|c| c.is_ascii_hexdigit())
3808 .collect();
3809 let short = if hex.len() >= 4 {
3810 &hex[hex.len() - 4..]
3811 } else {
3812 hex.as_str()
3813 };
3814 format!("Instance {id} (#{short})")
3815 }
3816
3817 pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
3819 let cat = self
3820 .inventory_item_category(&row.stack.template_id)
3821 .unwrap_or("");
3822 let label = self.inventory_row_base_label(row);
3823 let hint: String = if row.is_equip_shell {
3824 " [worn — Enter to unequip]".into()
3825 } else if row.is_chest_shell {
3826 let (locked, lodging_note) = match &row.from {
3827 flatland_protocol::InventoryLocation::Placed { container_id } => {
3828 let locked = self
3829 .placed_containers
3830 .iter()
3831 .find(|c| c.id == *container_id)
3832 .map(|c| c.locked)
3833 .unwrap_or(false);
3834 let lodging_note = self
3835 .lodging_occupancy_label(container_id)
3836 .map(|who| format!(" [lodging: {who}]"))
3837 .unwrap_or_default();
3838 (locked, lodging_note)
3839 }
3840 _ => (false, String::new()),
3841 };
3842 if locked {
3843 format!(" [locked — Enter pick up · l unlock]{lodging_note}")
3844 } else {
3845 format!(" [Enter pick up · l lock]{lodging_note}")
3846 }
3847 } else if cat == "key" {
3848 self.key_inventory_hint(&row.stack)
3849 } else {
3850 match cat {
3851 "weapon" => " [weapon]".into(),
3852 "container" => " [bag/chest/belt]".into(),
3853 "lodging" => " [worker lodging]".into(),
3854 "armor" => " [armor]".into(),
3855 _ => String::new(),
3856 }
3857 };
3858 let qty = if row.stack.quantity > 1 {
3859 format!(" ×{}", row.stack.quantity)
3860 } else {
3861 String::new()
3862 };
3863 let bindings =
3864 format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
3865 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3866 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3867 let mode = Self::grant_mode(&row.stack);
3868 format!(" [grant {effect} · {mode} — e apply]")
3869 } else {
3870 String::new()
3871 };
3872 let mass = self.stack_mass(&row.stack);
3873 let mass_kg = (mass >= 0.05).then_some(mass);
3874 let mass_str = mass_kg.map(|m| format!(" {m:.1} kg")).unwrap_or_default();
3875 let volume = self.container_volume_stats(row);
3876 let vol_str = self.container_volume_label(row);
3877
3878 let mut title = label.clone();
3879 title.push_str(&qty);
3880 if row.is_equip_shell {
3881 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3882 title.push_str(&format!(" ({})", body_slot_label(slot)));
3883 }
3884 }
3885
3886 InventoryRowView {
3887 depth: row.depth,
3888 text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
3889 title: format!("{title}{grant_hint}{bindings}"),
3890 mass_kg,
3891 volume,
3892 instance_tooltip: None,
3893 }
3894 }
3895
3896 fn push_browser_item(
3897 &self,
3898 lines: &mut Vec<InventoryBrowserLine>,
3899 row: &InventoryRow,
3900 global_idx: &mut usize,
3901 target: usize,
3902 highlight: bool,
3903 ambiguous_instance_keys: &HashSet<(String, String, String)>,
3904 ) {
3905 let mut view = self.format_inventory_row(row);
3906 if let Some(id) = row.stack.item_instance_id {
3907 let key = self.inventory_row_instance_identity_key(row);
3908 if ambiguous_instance_keys.contains(&key) {
3909 view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
3910 }
3911 }
3912 lines.push(InventoryBrowserLine::Item {
3913 selectable_index: *global_idx,
3914 selected: highlight && *global_idx == target,
3915 depth: view.depth,
3916 text: view.text,
3917 title: view.title,
3918 mass_kg: view.mass_kg,
3919 volume: view.volume,
3920 instance_tooltip: view.instance_tooltip,
3921 });
3922 *global_idx += 1;
3923 }
3924
3925 pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
3928 let mut lines = Vec::new();
3929 let target = self.inventory_menu_index;
3930 let highlight = !self.show_move_picker && !self.show_grant_picker;
3931 let filter = self.inventory_filter.as_str();
3932 let mut global_idx = 0usize;
3933 let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
3934
3935 match self.inventory_tab {
3936 InventoryTab::OnPerson => {
3937 lines.push(InventoryBrowserLine::Section("— Worn —".into()));
3938 let worn = self.worn_rows_filtered(filter);
3939 if worn.is_empty() {
3940 lines.push(InventoryBrowserLine::Hint(
3941 " (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
3942 ));
3943 } else {
3944 for row in &worn {
3945 if row.is_equip_shell {
3946 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3947 lines.push(InventoryBrowserLine::SlotLabel(format!(
3948 " {}:",
3949 body_slot_label(slot)
3950 )));
3951 }
3952 }
3953 self.push_browser_item(
3954 &mut lines,
3955 row,
3956 &mut global_idx,
3957 target,
3958 highlight,
3959 &ambiguous_instance_keys,
3960 );
3961 }
3962 }
3963
3964 lines.push(InventoryBrowserLine::Blank);
3965 lines.push(InventoryBrowserLine::Section(
3966 "— On you (loose, not worn) —".into(),
3967 ));
3968 let person = self.person_rows_filtered(filter);
3969 if person.is_empty() {
3970 lines.push(InventoryBrowserLine::Hint(" (empty)".into()));
3971 } else {
3972 let mut last_group: Option<&'static str> = None;
3973 for row in &person {
3974 if row.depth == 0 {
3975 let cat = row
3976 .stack
3977 .category
3978 .as_deref()
3979 .or_else(|| self.inventory_item_category(&row.stack.template_id))
3980 .unwrap_or("");
3981 let (group, _) = inventory_category_group(cat);
3982 if last_group != Some(group) {
3983 lines.push(InventoryBrowserLine::SlotLabel(format!(" {group}")));
3984 last_group = Some(group);
3985 }
3986 }
3987 self.push_browser_item(
3988 &mut lines,
3989 row,
3990 &mut global_idx,
3991 target,
3992 highlight,
3993 &ambiguous_instance_keys,
3994 );
3995 }
3996 }
3997 }
3998 InventoryTab::Nearby => {
3999 let nearby = self.nearby_containers();
4000 if nearby.is_empty() {
4001 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
4002 lines.push(InventoryBrowserLine::Hint(
4003 " (none within reach — walk up to a chest)".into(),
4004 ));
4005 lines.push(InventoryBrowserLine::Hint(
4006 " Select an on-person item, then m / Enter → move into chest.".into(),
4007 ));
4008 } else {
4009 let mut any_visible = false;
4010 for nc in &nearby {
4011 let shell = nc.rows.first();
4012 let contents: Vec<&InventoryRow> = if filter.is_empty() {
4013 nc.rows.iter().skip(1).collect()
4014 } else {
4015 let shell_hit = shell
4016 .map(|s| {
4017 let f = filter.to_ascii_lowercase();
4018 let name = s
4019 .stack
4020 .display_name
4021 .as_deref()
4022 .unwrap_or("")
4023 .to_ascii_lowercase();
4024 let tid = s.stack.template_id.to_ascii_lowercase();
4025 name.contains(&f) || tid.contains(&f)
4026 })
4027 .unwrap_or(false);
4028 if shell_hit {
4029 nc.rows.iter().skip(1).collect()
4030 } else {
4031 nc.rows
4032 .iter()
4033 .skip(1)
4034 .filter(|r| stack_matches_filter(&r.stack, filter))
4035 .collect()
4036 }
4037 };
4038 let shell_visible = filter.is_empty()
4039 || shell
4040 .map(|s| stack_matches_filter(&s.stack, filter))
4041 .unwrap_or(false)
4042 || !contents.is_empty();
4043 if !shell_visible && shell.is_some() {
4044 continue;
4045 }
4046 any_visible = true;
4047 lines.push(InventoryBrowserLine::Blank);
4048 let lock_note = if nc.view.locked && nc.view.accessible {
4049 " unlocked with your key"
4050 } else if nc.view.locked {
4051 " locked"
4052 } else {
4053 ""
4054 };
4055 lines.push(InventoryBrowserLine::Section(format!(
4056 "— {} ({:.0}m away){lock_note} —",
4057 nc.view.display_name, nc.distance_m
4058 )));
4059 if !nc.view.accessible {
4060 lines.push(InventoryBrowserLine::Hint(
4061 " locked — need the matching key (l to try)".into(),
4062 ));
4063 } else if nc.rows.is_empty() {
4064 lines.push(InventoryBrowserLine::Hint(
4065 " (empty — switch to On person, select an item, m to move in)"
4066 .into(),
4067 ));
4068 } else if let Some(shell_row) = shell {
4069 self.push_browser_item(
4070 &mut lines,
4071 shell_row,
4072 &mut global_idx,
4073 target,
4074 highlight,
4075 &ambiguous_instance_keys,
4076 );
4077 for row in contents {
4078 self.push_browser_item(
4079 &mut lines,
4080 row,
4081 &mut global_idx,
4082 target,
4083 highlight,
4084 &ambiguous_instance_keys,
4085 );
4086 }
4087 }
4088 }
4089 if !any_visible {
4090 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
4091 lines.push(InventoryBrowserLine::Hint(
4092 " (no matching items — clear filter with Esc)".into(),
4093 ));
4094 }
4095 }
4096 }
4097 }
4098 lines
4099 }
4100
4101 pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
4103 let mut opts = Vec::new();
4104 opts.push(MoveOption {
4105 label: "Relocate…".into(),
4106 kind: MoveOptionKind::RelocatePlaced {
4107 container_id: container_id.to_string(),
4108 },
4109 });
4110 opts.push(MoveOption {
4111 label: "On your person (loose)".into(),
4112 kind: MoveOptionKind::PickupPlaced {
4113 container_id: container_id.to_string(),
4114 nest_location: flatland_protocol::InventoryLocation::Root,
4115 nest_parent_instance_id: None,
4116 },
4117 });
4118 for (slot, item) in &self.worn {
4119 if item.category.as_deref() != Some("container") {
4120 continue;
4121 }
4122 if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
4123 continue;
4124 }
4125 let Some(parent_id) = item.item_instance_id else {
4126 continue;
4127 };
4128 let shell_name = item
4129 .display_name
4130 .clone()
4131 .unwrap_or_else(|| item.template_id.clone());
4132 opts.push(MoveOption {
4133 label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
4134 kind: MoveOptionKind::PickupPlaced {
4135 container_id: container_id.to_string(),
4136 nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
4137 nest_parent_instance_id: Some(parent_id),
4138 },
4139 });
4140 Self::append_chest_pickup_nested(
4142 &mut opts,
4143 container_id,
4144 flatland_protocol::InventoryLocation::Worn { slot: *slot },
4145 item,
4146 &format!("in {shell_name}"),
4147 );
4148 }
4149 opts.push(MoveOption {
4150 label: "Cancel".into(),
4151 kind: MoveOptionKind::Cancel,
4152 });
4153 opts
4154 }
4155
4156 fn append_chest_pickup_nested(
4157 opts: &mut Vec<MoveOption>,
4158 container_id: &str,
4159 location: flatland_protocol::InventoryLocation,
4160 parent: &flatland_protocol::ItemStack,
4161 context: &str,
4162 ) {
4163 for child in &parent.contents {
4164 if child.category.as_deref() != Some("container") {
4165 continue;
4166 }
4167 if !Self::is_volume_container_stack(child) {
4168 continue;
4169 }
4170 if child.world_placeable == Some(true) {
4172 continue;
4173 }
4174 let Some(child_id) = child.item_instance_id else {
4175 continue;
4176 };
4177 let name = child
4178 .display_name
4179 .clone()
4180 .unwrap_or_else(|| child.template_id.clone());
4181 opts.push(MoveOption {
4182 label: format!("{name} ({context})"),
4183 kind: MoveOptionKind::PickupPlaced {
4184 container_id: container_id.to_string(),
4185 nest_location: location.clone(),
4186 nest_parent_instance_id: Some(child_id),
4187 },
4188 });
4189 Self::append_chest_pickup_nested(
4190 opts,
4191 container_id,
4192 location.clone(),
4193 child,
4194 &format!("in {name}"),
4195 );
4196 }
4197 }
4198
4199 pub fn move_destinations_for(
4201 &self,
4202 from: &flatland_protocol::InventoryLocation,
4203 from_parent_instance_id: Option<uuid::Uuid>,
4204 moving_instance_id: Option<uuid::Uuid>,
4205 moving_template_id: &str,
4206 ) -> Vec<MoveOption> {
4207 let mut opts = Vec::new();
4208 if *from != flatland_protocol::InventoryLocation::Root {
4209 opts.push(MoveOption {
4210 label: "On your person (loose)".into(),
4211 kind: MoveOptionKind::Move {
4212 location: flatland_protocol::InventoryLocation::Root,
4213 parent_instance_id: None,
4214 },
4215 });
4216 }
4217 for (slot, item) in &self.worn {
4218 if item.category.as_deref() != Some("container") {
4219 continue;
4220 }
4221 let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4222 let shell_name = item
4223 .display_name
4224 .clone()
4225 .unwrap_or_else(|| item.template_id.clone());
4226
4227 if *slot != BodySlot::Waist
4229 && item.item_instance_id != moving_instance_id
4230 && Self::is_volume_container_stack(item)
4231 {
4232 Self::push_move_destination(
4233 &mut opts,
4234 format!("{shell_name} (worn {})", body_slot_label(*slot)),
4235 location.clone(),
4236 item.item_instance_id,
4237 from,
4238 from_parent_instance_id,
4239 );
4240 }
4241
4242 if *slot == BodySlot::Waist
4244 && Self::attaches_to_belt_loop(moving_template_id)
4245 && item.item_instance_id != moving_instance_id
4246 {
4247 Self::push_move_destination(
4248 &mut opts,
4249 format!("{shell_name} (belt loop)"),
4250 location.clone(),
4251 item.item_instance_id,
4252 from,
4253 from_parent_instance_id,
4254 );
4255 }
4256
4257 let context = if *slot == BodySlot::Waist {
4258 format!("on {shell_name}")
4259 } else {
4260 format!("in {shell_name}")
4261 };
4262 Self::append_nested_container_destinations(
4263 &mut opts,
4264 location,
4265 item,
4266 &context,
4267 from,
4268 from_parent_instance_id,
4269 moving_instance_id,
4270 );
4271 }
4272 for nc in self.nearby_containers() {
4273 if !nc.view.accessible {
4274 continue;
4275 }
4276 let location = flatland_protocol::InventoryLocation::Placed {
4277 container_id: nc.view.id.clone(),
4278 };
4279 Self::push_move_destination(
4280 &mut opts,
4281 format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
4282 location,
4283 nc.view.item_instance_id,
4284 from,
4285 from_parent_instance_id,
4286 );
4287 }
4288 let allow_drop = moving_instance_id
4289 .map(|id| !self.hand_equipped_instance_ids().contains(&id))
4290 .unwrap_or(true)
4291 && moving_instance_id
4292 .and_then(|id| self.stack_for_instance(id))
4293 .map(|stack| {
4294 !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
4295 })
4296 .unwrap_or(
4297 moving_template_id != KEY_TEMPLATE
4298 && moving_template_id != PROPERTY_DEED_TEMPLATE,
4299 );
4300 if allow_drop {
4301 opts.push(MoveOption {
4302 label: "Drop on the ground".into(),
4303 kind: MoveOptionKind::Drop,
4304 });
4305 }
4306 opts.push(MoveOption {
4307 label: "Cancel".into(),
4308 kind: MoveOptionKind::Cancel,
4309 });
4310 opts
4311 }
4312
4313 fn is_same_container_dest(
4314 dest_location: &flatland_protocol::InventoryLocation,
4315 dest_parent: Option<uuid::Uuid>,
4316 from: &flatland_protocol::InventoryLocation,
4317 from_parent: Option<uuid::Uuid>,
4318 ) -> bool {
4319 dest_location == from && dest_parent == from_parent
4320 }
4321
4322 fn push_move_destination(
4323 opts: &mut Vec<MoveOption>,
4324 label: String,
4325 location: flatland_protocol::InventoryLocation,
4326 parent_instance_id: Option<uuid::Uuid>,
4327 from: &flatland_protocol::InventoryLocation,
4328 from_parent_instance_id: Option<uuid::Uuid>,
4329 ) {
4330 if Self::is_same_container_dest(
4331 &location,
4332 parent_instance_id,
4333 from,
4334 from_parent_instance_id,
4335 ) {
4336 return;
4337 }
4338 opts.push(MoveOption {
4339 label,
4340 kind: MoveOptionKind::Move {
4341 location,
4342 parent_instance_id,
4343 },
4344 });
4345 }
4346
4347 fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
4348 stack.capacity_volume.is_some_and(|c| c > 0.0)
4349 }
4350
4351 fn attaches_to_belt_loop(template_id: &str) -> bool {
4352 matches!(template_id, "leather_pouch" | "dimensional_pouch")
4353 }
4354
4355 fn append_nested_container_destinations(
4356 opts: &mut Vec<MoveOption>,
4357 location: flatland_protocol::InventoryLocation,
4358 container: &flatland_protocol::ItemStack,
4359 context: &str,
4360 from: &flatland_protocol::InventoryLocation,
4361 from_parent_instance_id: Option<uuid::Uuid>,
4362 moving_instance_id: Option<uuid::Uuid>,
4363 ) {
4364 for child in &container.contents {
4365 if Self::is_volume_container_stack(child)
4366 && child.item_instance_id != moving_instance_id
4367 {
4368 let name = child
4369 .display_name
4370 .clone()
4371 .unwrap_or_else(|| child.template_id.clone());
4372 Self::push_move_destination(
4373 opts,
4374 format!("{name} ({context})"),
4375 location.clone(),
4376 child.item_instance_id,
4377 from,
4378 from_parent_instance_id,
4379 );
4380 }
4381 let nested_context = format!(
4382 "in {}",
4383 child.display_name.as_deref().unwrap_or(&child.template_id)
4384 );
4385 Self::append_nested_container_destinations(
4386 opts,
4387 location.clone(),
4388 child,
4389 &nested_context,
4390 from,
4391 from_parent_instance_id,
4392 moving_instance_id,
4393 );
4394 }
4395 }
4396
4397 fn clamp_inventory_indices(&mut self) {
4398 let n = self.inventory_selectable_rows().len();
4399 self.inventory_menu_index = if n == 0 {
4400 0
4401 } else {
4402 self.inventory_menu_index.min(n - 1)
4403 };
4404 if let Some(picker) = &self.move_picker {
4405 let pn = picker.options.len();
4406 self.move_picker_index = if pn == 0 {
4407 0
4408 } else {
4409 self.move_picker_index.min(pn - 1)
4410 };
4411 }
4412 }
4413
4414 fn sync_interior_map_context(&mut self) {
4419 if self.effective_inside_building().is_none() {
4420 self.interior_map = None;
4421 if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
4422 self.z_platforms = platforms;
4423 self.z_transitions = transitions;
4424 }
4425 return;
4426 }
4427 self.sync_interior_z_bands();
4428 }
4429
4430 fn sync_interior_z_bands(&mut self) {
4432 if self.effective_inside_building().is_some() {
4433 if let Some(map) = &self.interior_map {
4434 if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
4435 if self.z_bands_outdoor_backup.is_none() {
4436 self.z_bands_outdoor_backup = Some((
4437 std::mem::take(&mut self.z_platforms),
4438 std::mem::take(&mut self.z_transitions),
4439 ));
4440 }
4441 self.z_platforms = map.z_platforms.clone();
4442 self.z_transitions = map.z_transitions.clone();
4443 }
4444 }
4445 }
4446 }
4447
4448 fn apply_snapshot_fields(
4449 &mut self,
4450 snapshot: &flatland_protocol::Snapshot,
4451 entity_id: EntityId,
4452 ) {
4453 self.tick = snapshot.tick;
4454 self.chunk_rev = snapshot.chunk_rev;
4455 self.content_rev = snapshot.content_rev;
4456 self.publish_rev = snapshot.publish_rev;
4457 self.resource_nodes = snapshot.resource_nodes.clone();
4458 self.ground_drops = snapshot.ground_drops.clone();
4459 self.placed_containers = snapshot.placed_containers.clone();
4460 self.world_x0 = snapshot.world_x0;
4461 self.world_y0 = snapshot.world_y0;
4462 self.world_width_m = snapshot.world_width_m;
4463 self.world_height_m = snapshot.world_height_m;
4464 self.world_clock = snapshot.world_clock;
4465 self.terrain_zones = snapshot.terrain_zones.clone();
4466 self.z_platforms = snapshot.z_platforms.clone();
4467 self.z_transitions = snapshot.z_transitions.clone();
4468 self.z_bands_outdoor_backup = None;
4470 self.buildings = snapshot.buildings.clone();
4471 self.doors = snapshot.doors.clone();
4472 self.interior_map = snapshot.interior_map.clone();
4473 self.npcs = snapshot.npcs.clone();
4474 self.blueprints = snapshot.blueprints.clone();
4475 self.building_materials = snapshot.building_materials.clone();
4476 self.sync_inventory_from_stacks(&snapshot.inventory);
4477 self.player = snapshot
4478 .entities
4479 .iter()
4480 .find(|e| e.id == entity_id)
4481 .cloned();
4482 self.entities = snapshot.entities.clone();
4483 self.quest_log = snapshot.quest_log.clone();
4484 self.apply_hired_workers(snapshot.hired_workers.clone());
4485 self.interactables = snapshot.interactables.clone();
4486 self.ledger = snapshot.ledger.clone();
4487 self.career = snapshot.career.clone();
4488 self.combat_fx = snapshot.combat_fx.clone();
4489 self.ground_hazards = snapshot.ground_hazards.clone();
4490 self.property_zones = snapshot.property_zones.clone();
4491 self.tax_zones = snapshot.tax_zones.clone();
4492 self.growth_zones = snapshot.growth_zones.clone();
4493 self.biome_zones = snapshot.biome_zones.clone();
4494 self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
4495 self.property_plots = snapshot.property_plots.clone();
4496 self.property_plot_settings = snapshot.property_plot_settings.clone();
4497 if self.effective_inside_building().is_some() {
4500 self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
4501 }
4502 self.sync_interior_map_context();
4503 self.refresh_whisper_range();
4504 self.sync_gameplay_audio();
4505 }
4506
4507 fn refresh_inventory_ui(&mut self) {
4511 if let Some(picker) = &self.move_picker {
4512 let instance_id = picker.item_instance_id;
4513 let still_exists = self
4514 .inventory_selectable_rows()
4515 .iter()
4516 .any(|r| r.stack.item_instance_id == Some(instance_id));
4517 if !still_exists {
4518 self.move_picker = None;
4519 self.show_move_picker = false;
4520 }
4521 }
4522 if let Some(picker) = &self.destroy_picker {
4523 let instance_id = picker.item_instance_id;
4524 let still_exists = self
4525 .inventory_selectable_rows()
4526 .iter()
4527 .any(|r| r.stack.item_instance_id == Some(instance_id));
4528 if !still_exists {
4529 self.destroy_picker = None;
4530 self.show_destroy_picker = false;
4531 self.destroy_confirm_pending = false;
4532 }
4533 }
4534 self.clamp_inventory_indices();
4535 }
4536
4537 fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
4543 let selected_id = self
4544 .hired_workers
4545 .get(self.workers_menu_index)
4546 .map(|w| w.instance_id.clone());
4547 workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
4548 let now = Instant::now();
4549 for worker in &workers {
4550 let was_hit = self
4551 .hired_workers
4552 .iter()
4553 .find(|previous| previous.instance_id == worker.instance_id)
4554 .is_some_and(|previous| {
4555 matches!(
4556 worker.mode,
4557 flatland_protocol::WorkerModeView::Defender
4558 ) && worker.vitals.health_pct + 0.01 < previous.vitals.health_pct
4559 });
4560 if was_hit {
4561 self.worker_health_ring_until.insert(
4562 worker.entity_id,
4563 now + WORKER_HEALTH_RING_HOLD,
4564 );
4565 }
4566 }
4567 let worker_entity_ids: HashSet<EntityId> =
4568 workers.iter().map(|worker| worker.entity_id).collect();
4569 self.worker_health_ring_until
4570 .retain(|entity_id, _| worker_entity_ids.contains(entity_id));
4571 for w in &workers {
4572 let prev_err = self
4573 .hired_workers
4574 .iter()
4575 .find(|p| p.instance_id == w.instance_id)
4576 .and_then(|p| p.last_error.as_deref());
4577 let new_err = w.last_error.as_deref();
4578 if new_err != prev_err {
4579 if let Some(err) = new_err {
4580 if !worker_error_is_transient(err) {
4581 self.push_log(format!("Worker {}: {err}", w.label));
4582 }
4583 }
4584 }
4585 }
4586 let mut next_display = BTreeMap::new();
4587 let mut next_errors = BTreeMap::new();
4588 for w in &workers {
4589 let mut sticky = self
4590 .worker_step_display
4591 .remove(&w.instance_id)
4592 .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
4593 sticky.observe(&w.step_label, now);
4594 next_display.insert(w.instance_id.clone(), sticky);
4595
4596 let mut err_sticky = self
4597 .worker_error_display
4598 .remove(&w.instance_id)
4599 .unwrap_or_default();
4600 err_sticky.observe(w.last_error.as_deref(), now);
4601 if err_sticky.shown(now).is_some() {
4602 next_errors.insert(w.instance_id.clone(), err_sticky);
4603 }
4604 }
4605 self.worker_step_display = next_display;
4606 self.worker_error_display = next_errors;
4607 self.hired_workers = workers;
4608 self.sync_worker_take_picker_from_hired();
4609 if let Some(id) = selected_id {
4610 if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
4611 self.workers_menu_index = idx;
4612 return;
4613 }
4614 }
4615 if self.workers_menu_index >= self.hired_workers.len() {
4616 self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
4617 }
4618 }
4619
4620 fn sync_worker_take_picker_from_hired(&mut self) {
4622 if !self.show_worker_take_picker {
4623 return;
4624 }
4625 let Some(picker) = self.worker_take_picker.clone() else {
4626 return;
4627 };
4628 let Some(worker) = self
4629 .hired_workers
4630 .iter()
4631 .find(|w| w.instance_id == picker.worker_instance_id)
4632 .cloned()
4633 else {
4634 self.show_worker_take_picker = false;
4635 self.worker_take_picker = None;
4636 self.worker_take_picker_index = 0;
4637 return;
4638 };
4639 let options: Vec<WorkerGiveOption> = worker
4640 .inventory
4641 .iter()
4642 .filter_map(|stack| {
4643 let item_instance_id = stack.item_instance_id?;
4644 let label = stack
4645 .display_name
4646 .clone()
4647 .unwrap_or_else(|| stack.template_id.clone());
4648 let label = if stack.quantity > 1 {
4649 format!("{label} ×{}", stack.quantity)
4650 } else {
4651 label
4652 };
4653 Some(WorkerGiveOption {
4654 item_instance_id,
4655 label,
4656 quantity: stack.quantity,
4657 template_id: stack.template_id.clone(),
4658 })
4659 })
4660 .collect();
4661 if options.is_empty() {
4662 self.show_worker_take_picker = false;
4663 self.worker_take_picker = None;
4664 self.worker_take_picker_index = 0;
4665 return;
4666 }
4667 let prev_id = picker
4668 .options
4669 .get(self.worker_take_picker_index)
4670 .map(|o| o.item_instance_id);
4671 let idx = prev_id
4672 .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
4673 .unwrap_or(0)
4674 .min(options.len().saturating_sub(1));
4675 let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
4676 let quantity = picker.quantity.clamp(1, max_qty);
4677 self.worker_take_picker_index = idx;
4678 self.worker_take_picker = Some(WorkerTakePicker {
4679 worker_instance_id: picker.worker_instance_id,
4680 worker_label: picker.worker_label,
4681 options,
4682 quantity,
4683 });
4684 }
4685
4686 pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
4688 self.worker_step_display
4689 .get(worker_instance_id)
4690 .map(|s| s.shown.as_str())
4691 .or_else(|| {
4692 self.hired_workers
4693 .iter()
4694 .find(|w| w.instance_id == worker_instance_id)
4695 .map(|w| w.step_label.as_str())
4696 })
4697 .unwrap_or("")
4698 }
4699
4700 pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
4702 let now = Instant::now();
4703 self.worker_error_display
4704 .get(worker_instance_id)
4705 .and_then(|s| s.shown(now))
4706 .or_else(|| {
4707 self.hired_workers
4708 .iter()
4709 .find(|w| w.instance_id == worker_instance_id)
4710 .and_then(|w| w.last_error.as_deref())
4711 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
4712 })
4713 .filter(|e| !worker_error_is_hud_noise(e))
4714 }
4715
4716 fn apply_combat_hud(&mut self, combat: &CombatHud) {
4717 self.in_combat = combat.in_combat;
4718 self.auto_attack = combat.auto_attack;
4719 self.combat_has_los = combat.has_los;
4720 self.attack_cd_ticks = combat.attack_cd_ticks;
4721 self.gcd_ticks = combat.gcd_ticks;
4722 self.weapon_ability_id = combat.ability_id.clone();
4723 self.mainhand_template_id = combat.mainhand_template_id.clone();
4724 self.mainhand_label = combat.mainhand_label.clone();
4725 self.mainhand_instance_id = combat.mainhand_instance_id;
4726 self.offhand_template_id = combat.offhand_template_id.clone();
4727 self.offhand_label = combat.offhand_label.clone();
4728 self.offhand_instance_id = combat.offhand_instance_id;
4729 self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
4730 1
4731 } else {
4732 combat.mainhand_hand_slots
4733 };
4734 self.defense = combat.defense.clone();
4735 self.worn = combat.worn.iter().cloned().collect();
4736 self.carry_mass = combat.carry_mass;
4737 self.carry_mass_max = combat.carry_mass_max;
4738 self.encumbrance = combat.encumbrance;
4739 self.cast_progress = combat.cast.clone();
4740 self.timed_channel = combat.timed_channel.clone();
4741 self.plot_build_offer = combat.plot_build.clone();
4742 self.ability_cooldowns = combat.ability_cooldowns.clone();
4743 self.blocking_active = combat.blocking_active;
4744 self.max_target_slots = combat.max_target_slots.max(1);
4745 self.combat_slots = combat.slots.clone();
4746 self.rotation_presets = combat.rotation_presets.clone();
4747 self.known_abilities = combat.known_abilities.clone();
4748 self.ability_meta = combat
4749 .ability_meta
4750 .iter()
4751 .cloned()
4752 .map(|meta| (meta.id.clone(), meta))
4753 .collect();
4754 self.ability_mastery = combat
4755 .ability_mastery
4756 .iter()
4757 .cloned()
4758 .map(|row| (row.ability_id.clone(), row))
4759 .collect();
4760 self.hotbar = combat.hotbar.clone();
4761 self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
4762 self.keychain_stacks = combat.keychain.clone();
4763 self.whisper_pouch_stacks = combat.whisper_pouch.clone();
4764 self.combat_target_detail = combat.target.clone();
4765 self.statuses = combat.statuses.clone();
4766 self.combat_target = combat.target_entity_id;
4767 if combat.progression_xp_base > 0.0 {
4768 self.progression_curve = Some(flatland_protocol::ProgressionCurve {
4769 baseline_display: combat.progression_baseline,
4770 xp_base: combat.progression_xp_base,
4771 xp_growth: combat.progression_xp_growth,
4772 });
4773 }
4774 if let Some(xp) = &combat.progression_xp {
4775 if let Some(player) = &mut self.player {
4776 player.progression_xp = Some(xp.clone());
4777 if let Some(attrs) = combat.attributes {
4778 player.attributes = Some(attrs);
4779 }
4780 if let Some(skills) = &combat.skills {
4781 player.skills = Some(skills.clone());
4782 }
4783 }
4784 }
4785 if let Some(label) = &combat.target_label {
4786 self.combat_target_label = Some(label.clone());
4787 } else if let Some(id) = combat.target_entity_id {
4788 self.combat_target_label = self
4789 .entities
4790 .iter()
4791 .find(|e| e.id == id)
4792 .map(|e| e.label.clone())
4793 .or_else(|| self.combat_target_label.clone());
4794 }
4795 self.refresh_inventory_ui();
4796 }
4797
4798 pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
4800 self.combat_slots
4801 .iter()
4802 .find(|s| s.slot_index == slot)
4803 .and_then(|s| s.target_entity_id)
4804 .or_else(|| if slot == 1 { self.combat_target } else { None })
4805 }
4806
4807 pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
4809 self.ability_meta
4810 .get(ability_id)
4811 .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
4812 .unwrap_or(self.ground_target.is_some())
4815 }
4816
4817 pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
4819 self.ability_meta
4820 .get(ability_id)
4821 .map(|meta| meta.aim_mode == "ground")
4822 .unwrap_or(false)
4823 }
4824
4825 pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
4828 self.ability_meta
4829 .get(ability_id)
4830 .map(|meta| meta.auto_rotation_eligible)
4831 .unwrap_or(true)
4832 }
4833
4834 pub fn set_ground_target(&mut self, x: f32, y: f32) {
4836 self.ground_target = Some((x, y, 0.0));
4837 }
4838
4839 pub fn clear_ground_target(&mut self) {
4841 self.ground_target = None;
4842 }
4843
4844 pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
4847 if !(1..=9).contains(&slot_1_to_9) {
4848 return None;
4849 }
4850 self.hotbar
4851 .get((slot_1_to_9 - 1) as usize)
4852 .and_then(|a| a.as_deref())
4853 .filter(|id| !id.is_empty())
4854 }
4855
4856 pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
4858 let binding = self.hotbar_ability(slot_1_to_9)?;
4859 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
4860 let name = self
4861 .inventory_hints
4862 .get(template_id)
4863 .map(|h| h.display_name.as_str())
4864 .unwrap_or(template_id);
4865 let qty = self.inventory.get(template_id).copied().unwrap_or(0);
4866 Some(format!("{name}×{qty}"))
4867 } else {
4868 Some(binding.to_string())
4869 }
4870 }
4871
4872 pub fn loadout_ability_choices(&self) -> Vec<String> {
4874 let mut out = self.known_abilities.clone();
4875 let weapon = self.weapon_ability_id.trim();
4876 if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
4877 out.push(weapon.to_string());
4878 }
4879 out
4880 }
4881
4882 pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
4884 let mut out = Vec::new();
4885 for ability in self.loadout_ability_choices() {
4886 let meta = if ability == self.weapon_ability_id {
4887 Some("weapon".into())
4888 } else {
4889 None
4890 };
4891 out.push(LoadoutHotbarChoice {
4892 binding: ability.clone(),
4893 label: ability,
4894 meta,
4895 });
4896 }
4897 let mut consumables: Vec<(String, String, u32)> = Vec::new();
4898 for stack in &self.inventory_stacks {
4899 if Self::stack_is_item_grant(stack) {
4900 continue;
4901 }
4902 if self.inventory_item_category(&stack.template_id) != Some("consumable") {
4903 continue;
4904 }
4905 let qty = stack.quantity.max(1);
4906 if let Some((_, _, existing)) = consumables
4907 .iter_mut()
4908 .find(|(id, _, _)| id == &stack.template_id)
4909 {
4910 *existing = existing.saturating_add(qty);
4911 } else {
4912 let label = stack
4913 .display_name
4914 .clone()
4915 .or_else(|| {
4916 self.inventory_hints
4917 .get(&stack.template_id)
4918 .map(|h| h.display_name.clone())
4919 })
4920 .unwrap_or_else(|| stack.template_id.clone());
4921 consumables.push((stack.template_id.clone(), label, qty));
4922 }
4923 }
4924 consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
4925 for (template_id, label, qty) in consumables {
4926 out.push(LoadoutHotbarChoice {
4927 binding: flatland_protocol::hotbar_consumable_binding(&template_id),
4928 label: format!("{label} ×{qty}"),
4929 meta: Some("use".into()),
4930 });
4931 }
4932 out
4933 }
4934
4935 pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
4937 self.combat_candidates()
4938 }
4939
4940 pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
4942 let (px, py) = self.player_position();
4943 let dist = |id: EntityId| {
4944 self.entities
4945 .iter()
4946 .find(|e| e.id == id)
4947 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4948 .unwrap_or(f32::MAX)
4949 };
4950
4951 let mut allies = Vec::new();
4952 if let Some(me) = self.player.as_ref() {
4954 let alive = me
4955 .vitals
4956 .as_ref()
4957 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4958 .unwrap_or(true);
4959 if alive {
4960 allies.push((self.entity_id, "Yourself".into()));
4961 }
4962 }
4963 for entity in &self.entities {
4964 if entity.id == self.entity_id {
4965 continue;
4966 }
4967 if entity.vitals.is_some() {
4968 let alive = entity
4969 .vitals
4970 .as_ref()
4971 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4972 .unwrap_or(true);
4973 if alive {
4974 allies.push((entity.id, entity.label.clone()));
4975 }
4976 }
4977 }
4978 allies.sort_by(|(a, _), (b, _)| {
4979 if *a == self.entity_id {
4980 return std::cmp::Ordering::Less;
4981 }
4982 if *b == self.entity_id {
4983 return std::cmp::Ordering::Greater;
4984 }
4985 dist(*a)
4986 .partial_cmp(&dist(*b))
4987 .unwrap_or(std::cmp::Ordering::Equal)
4988 });
4989
4990 let mut monsters = self.combat_candidates();
4991 monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
4992 allies.into_iter().chain(monsters).collect()
4993 }
4994
4995 fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
4996 match slot_index {
4997 2 => self.t2_candidates(),
4998 _ => self.t1_candidates(),
4999 }
5000 }
5001
5002 pub fn pick_combat_target_at(
5004 &self,
5005 wx: f32,
5006 wy: f32,
5007 slot_index: u8,
5008 radius_m: f32,
5009 ) -> Option<(EntityId, String)> {
5010 let mut best: Option<(f32, EntityId, String)> = None;
5011 for (id, label) in self.candidates_for_slot(slot_index) {
5012 let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
5013 if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
5015 let d = distance(wx, wy, npc.x, npc.y);
5016 if d <= radius_m {
5017 best = match best {
5018 Some((bd, _, _)) if bd <= d => best,
5019 _ => Some((d, id, label)),
5020 };
5021 }
5022 }
5023 continue;
5024 };
5025 let d = distance(
5026 wx,
5027 wy,
5028 entity.transform.position.x,
5029 entity.transform.position.y,
5030 );
5031 if d <= radius_m {
5032 best = match best {
5033 Some((bd, _, _)) if bd <= d => best,
5034 _ => Some((d, id, label)),
5035 };
5036 }
5037 }
5038 best.map(|(_, id, label)| (id, label))
5039 }
5040
5041 pub(crate) fn restore_from_welcome(
5043 &mut self,
5044 session_id: SessionId,
5045 entity_id: EntityId,
5046 snapshot: &flatland_protocol::Snapshot,
5047 ) {
5048 self.clear_harvest_state();
5049 self.disconnect_reason = None;
5050 self.show_stats = false;
5051 self.show_craft_menu = false;
5052 self.show_shop_menu = false;
5053 self.shop_catalog = None;
5054 self.show_inventory_menu = false;
5055 self.session_id = session_id;
5056 self.entity_id = entity_id;
5057 self.connected = true;
5058 self.apply_snapshot_fields(snapshot, entity_id);
5059 if let Some(combat) = &snapshot.combat {
5060 self.apply_combat_hud(combat);
5061 let stacks = self.inventory_stacks.clone();
5062 self.sync_inventory_from_stacks(&stacks);
5063 }
5064 }
5065
5066 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
5067 self.tick = delta.tick;
5068 self.world_clock = delta.world_clock;
5069
5070 if delta.entities.is_empty() {
5072 self.ground_drops = delta.ground_drops.clone();
5073 self.combat_fx = delta.combat_fx.clone();
5074 self.ground_hazards = delta.ground_hazards.clone();
5075 self.property_plots = delta.property_plots.clone();
5076 self.apply_terrain_overlays(&delta.terrain_overlays);
5077 if let Some(combat) = &delta.combat {
5078 self.apply_combat_hud(combat);
5079 let stacks = self.inventory_stacks.clone();
5080 self.sync_inventory_from_stacks(&stacks);
5081 }
5082 self.refresh_whisper_range();
5084 self.sync_gameplay_audio();
5085 return;
5086 }
5087 if !delta.buildings.is_empty() {
5088 self.buildings = delta.buildings.clone();
5089 }
5090 if !delta.blueprints.is_empty() {
5091 self.blueprints = delta.blueprints.clone();
5092 }
5093 if !delta.building_materials.is_empty() {
5094 self.building_materials = delta.building_materials.clone();
5095 }
5096 self.sync_inventory_from_stacks(&delta.inventory);
5097
5098 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
5099 self.player = Some(updated.clone());
5100 }
5101 self.entities = delta.entities.clone();
5102 if self.player.is_none() {
5103 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
5104 }
5105
5106 self.sync_interior_map_context();
5107
5108 if !delta.resource_nodes.is_empty() {
5112 self.resource_nodes = delta.resource_nodes.clone();
5113 } else if delta.interior_map.is_some() || self.effective_inside_building().is_some() {
5114 self.resource_nodes = delta.resource_nodes.clone();
5115 }
5116 self.ground_drops = delta.ground_drops.clone();
5117 self.placed_containers = delta.placed_containers.clone();
5119 if !delta.doors.is_empty() {
5120 self.doors = delta.doors.clone();
5121 }
5122 if self.effective_inside_building().is_some() {
5123 if let Some(map) = &delta.interior_map {
5124 self.interior_map = Some(map.clone());
5125 }
5126 } else {
5127 self.interior_map = None;
5128 }
5129 self.sync_interior_z_bands();
5130 self.npcs = delta.npcs.clone();
5132 if !delta.quest_log.is_empty() {
5133 self.quest_log = delta.quest_log.clone();
5134 }
5135 self.apply_hired_workers(delta.hired_workers.clone());
5136 if !delta.interactables.is_empty() {
5137 self.interactables = delta.interactables.clone();
5138 }
5139 if delta.ledger.is_some() {
5140 self.ledger = delta.ledger.clone();
5141 }
5142 if delta.career.is_some() {
5143 self.career = delta.career.clone();
5144 }
5145 self.combat_fx = delta.combat_fx.clone();
5146 self.ground_hazards = delta.ground_hazards.clone();
5147 if !delta.property_plots.is_empty() {
5149 self.property_plots = delta.property_plots.clone();
5150 }
5151 self.apply_terrain_overlays(&delta.terrain_overlays);
5152 if let Some(combat) = &delta.combat {
5153 self.apply_combat_hud(combat);
5154 let stacks = self.inventory_stacks.clone();
5155 self.sync_inventory_from_stacks(&stacks);
5156 } else {
5157 self.refresh_inventory_ui();
5158 }
5159 self.refresh_whisper_range();
5160 self.sync_gameplay_audio();
5161 }
5162
5163 fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
5166 self.terrain_zones.retain(|z| !z.id.starts_with("rt:"));
5167 self.terrain_zones.extend(overlays.iter().cloned());
5168 }
5169
5170 fn refresh_whisper_range(&mut self) {
5173 let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
5174 return;
5175 };
5176 let (px, py) = self.player_position();
5177 let in_range = self.entities.iter().any(|e| {
5178 e.id == peer
5179 && distance(px, py, e.transform.position.x, e.transform.position.y)
5180 <= INTERACTION_RADIUS_M
5181 });
5182 if !in_range {
5183 self.social_chat.cancel_whisper_out_of_range();
5184 }
5185 }
5186
5187 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
5189 let (px, py) = self.player_position();
5190 let mut out = Vec::new();
5191 for npc in &self.npcs {
5192 let Some(eid) = npc.entity_id else {
5193 continue;
5194 };
5195 let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
5196 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
5197 if alive && has_hp {
5198 out.push((eid, npc.label.clone()));
5199 }
5200 }
5201 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
5202 let dist = |id: EntityId| {
5203 self.entities
5204 .iter()
5205 .find(|e| e.id == id)
5206 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
5207 .unwrap_or(f32::MAX)
5208 };
5209 dist(*a_id)
5210 .partial_cmp(&dist(*b_id))
5211 .unwrap_or(std::cmp::Ordering::Equal)
5212 .then_with(|| a_label.cmp(b_label))
5213 .then_with(|| a_id.cmp(b_id))
5214 });
5215 out
5216 }
5217
5218 pub fn refresh_combat_target_label(&mut self) {
5219 let Some(id) = self.combat_target else {
5220 return;
5221 };
5222 if let Some((_, label)) = self
5223 .combat_candidates()
5224 .into_iter()
5225 .find(|(eid, _)| *eid == id)
5226 {
5227 self.combat_target_label = Some(label);
5228 } else if let Some(label) = self
5229 .entities
5230 .iter()
5231 .find(|e| e.id == id)
5232 .map(|e| e.label.clone())
5233 {
5234 self.combat_target_label = Some(label);
5235 }
5236 }
5237
5238 pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
5239 self.quest_log
5240 .iter()
5241 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
5242 .collect()
5243 }
5244
5245 pub fn has_worker_lodging(&self) -> bool {
5247 self.free_worker_lodging_slots() > 0
5248 }
5249
5250 pub fn free_worker_lodging_slots(&self) -> i64 {
5252 let slots: u32 = self
5253 .placed_containers
5254 .iter()
5255 .filter(|c| match (self.character_id, c.owner_character_id) {
5256 (Some(me), Some(owner)) => me == owner,
5257 (Some(_), None) => false,
5258 (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
5259 })
5260 .map(|c| c.worker_lodging_capacity.unwrap_or(0))
5261 .sum();
5262 let used = self.hired_workers.len() as u32;
5263 slots as i64 - used as i64
5264 }
5265
5266 pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
5268 let mut names: Vec<String> = self
5269 .hired_workers
5270 .iter()
5271 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
5272 .map(|w| w.label.clone())
5273 .collect();
5274 names.sort();
5275 names
5276 }
5277
5278 pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
5280 let is_lodging = self
5281 .placed_containers
5282 .iter()
5283 .find(|c| c.id == container_id)
5284 .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
5285 if !is_lodging {
5286 return None;
5287 }
5288 let names = self.lodging_occupant_labels(container_id);
5289 Some(if names.is_empty() {
5290 "vacant".into()
5291 } else {
5292 names.join(", ")
5293 })
5294 }
5295
5296 pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
5297 self.quest_log
5298 .iter()
5299 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
5300 .or_else(|| {
5301 self.quest_log
5302 .iter()
5303 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
5304 })
5305 }
5306
5307 pub fn nearby_lockable_door(&self) -> bool {
5309 let (px, py) = self.player_position();
5310 self.doors
5311 .iter()
5312 .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M)
5313 }
5314
5315 pub fn nearby_open_player_door(&self) -> bool {
5317 if self.effective_inside_building().is_some() {
5318 return false;
5319 }
5320 let (px, py) = self.player_position();
5321 self.doors.iter().any(|d| {
5322 if !d.open || d.locked {
5323 return false;
5324 }
5325 let player_house = self
5326 .buildings
5327 .iter()
5328 .find(|b| b.id == d.building_id)
5329 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5330 player_house && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M
5331 })
5332 }
5333
5334 pub fn nearby_player_exit_door(&self) -> bool {
5336 let Some(bid) = self.effective_inside_building() else {
5337 return false;
5338 };
5339 let (px, py) = self.player_position();
5340 self.doors.iter().any(|d| {
5341 if d.building_id != bid || d.portal.is_none() {
5342 return false;
5343 }
5344 let player_house = self
5345 .buildings
5346 .iter()
5347 .find(|b| b.id == d.building_id)
5348 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5349 player_house && (d.x - px).hypot(d.y - py) <= 1.5
5350 })
5351 }
5352
5353 pub fn nearest_interact_target(&self) -> Option<String> {
5355 let (px, py) = self.player_position();
5356 let inside = self.effective_inside_building();
5357
5358 #[derive(Clone, Copy, PartialEq, Eq)]
5359 enum Kind {
5360 Player,
5361 Npc,
5362 HiredWorker,
5363 QuestBoard,
5364 ExitDoor,
5365 EnterDoor,
5366 Well,
5367 Water,
5368 }
5369
5370 fn kind_priority(kind: Kind) -> u8 {
5371 match kind {
5372 Kind::Player => 0,
5373 Kind::Npc => 0,
5374 Kind::HiredWorker => 0,
5375 Kind::QuestBoard => 1,
5376 Kind::ExitDoor => 2,
5377 Kind::EnterDoor => 3,
5378 Kind::Well => 4,
5379 Kind::Water => 5,
5380 }
5381 }
5382
5383 let mut best: Option<(f32, Kind, String)> = None;
5384
5385 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
5386 if dist > max {
5387 return;
5388 }
5389 let replace = match best {
5390 None => true,
5391 Some((bd, _bk, _)) if dist < bd - 0.05 => true,
5392 Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
5393 kind_priority(kind) < kind_priority(bk)
5394 }
5395 _ => false,
5396 };
5397 if replace {
5398 best = Some((dist, kind, id));
5399 }
5400 };
5401
5402 for npc in &self.npcs {
5403 consider(
5404 distance(px, py, npc.x, npc.y),
5405 INTERACTION_RADIUS_M,
5406 Kind::Npc,
5407 npc.id.clone(),
5408 );
5409 }
5410
5411 for worker in &self.hired_workers {
5412 consider(
5413 distance(px, py, worker.x, worker.y),
5414 INTERACTION_RADIUS_M,
5415 Kind::HiredWorker,
5416 worker.instance_id.clone(),
5417 );
5418 }
5419
5420 for entity in &self.entities {
5421 if entity.id == self.entity_id
5422 || entity.vitals.is_none()
5423 || entity.label.trim().is_empty()
5424 {
5425 continue;
5426 }
5427 if self.hired_workers.iter().any(|w| w.entity_id == entity.id) {
5429 continue;
5430 }
5431 consider(
5432 distance(
5433 px,
5434 py,
5435 entity.transform.position.x,
5436 entity.transform.position.y,
5437 ),
5438 INTERACTION_RADIUS_M,
5439 Kind::Player,
5440 entity.id.to_string(),
5441 );
5442 }
5443
5444 for door in &self.doors {
5445 if let Some(ref bid) = inside {
5446 if door.building_id != *bid {
5447 continue;
5448 }
5449 let is_exit = door.portal.is_some();
5450 let max = if is_exit {
5451 INTERACTION_RADIUS_M
5452 } else {
5453 DOOR_INTERACTION_RADIUS_M
5454 };
5455 let kind = if is_exit {
5456 Kind::ExitDoor
5457 } else {
5458 Kind::EnterDoor
5459 };
5460 consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
5461 continue;
5462 }
5463 consider(
5464 distance(px, py, door.x, door.y),
5465 DOOR_INTERACTION_RADIUS_M,
5466 Kind::EnterDoor,
5467 door.id.clone(),
5468 );
5469 }
5470
5471 if inside.is_none() {
5472 for inter in &self.interactables {
5473 if inter.kind == "quest_board" {
5474 consider(
5475 distance(px, py, inter.x, inter.y),
5476 QUEST_BOARD_INTERACTION_RADIUS_M,
5477 Kind::QuestBoard,
5478 inter.id.clone(),
5479 );
5480 }
5481 }
5482 for building in &self.buildings {
5483 if !building.tags.iter().any(|t| t == "well") {
5484 continue;
5485 }
5486 consider(
5487 distance(px, py, building.x, building.y),
5488 INTERACTION_RADIUS_M,
5489 Kind::Well,
5490 building.id.clone(),
5491 );
5492 }
5493 if self.in_shallow_water() {
5494 consider(
5495 0.0,
5496 INTERACTION_RADIUS_M,
5497 Kind::Water,
5498 "water_source".into(),
5499 );
5500 }
5501 }
5502
5503 best.map(|(_, _, id)| id)
5504 }
5505
5506 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
5508 if self.effective_inside_building().is_some() {
5509 return None;
5510 }
5511 let (px, py) = self.player_position();
5512 self.interactables
5513 .iter()
5514 .filter(|i| i.kind == "quest_board")
5515 .map(|i| {
5516 let label = if i.label.is_empty() {
5517 "Quest board".to_string()
5518 } else {
5519 i.label.clone()
5520 };
5521 (label, distance(px, py, i.x, i.y))
5522 })
5523 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
5524 }
5525
5526 pub fn template_display_name(&self, template_id: &str) -> String {
5528 self.inventory_hints
5529 .get(template_id)
5530 .map(|h| h.display_name.clone())
5531 .filter(|n| !n.is_empty())
5532 .unwrap_or_else(|| humanize_template_id(template_id))
5533 }
5534
5535 pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
5537 if !display_name.is_empty() {
5538 display_name.to_string()
5539 } else {
5540 self.template_display_name(template_id)
5541 }
5542 }
5543
5544 pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
5545 self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
5546 }
5547
5548 pub fn blueprint_ingredient_label(
5549 &self,
5550 input: &flatland_protocol::BlueprintIngredientView,
5551 ) -> String {
5552 self.blueprint_item_label(&input.template_id, &input.display_name)
5553 }
5554
5555 pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
5556 self.blueprint_item_label(&tool.item, &tool.display_name)
5557 }
5558
5559 pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
5561 use crate::worker_route_editor::{
5562 node_candidates, node_candidates_stable, route_editor_lodging_anchor,
5563 };
5564 let lodging = self
5565 .worker_route_editor
5566 .as_ref()
5567 .and_then(|ed| ed.lodging_container_id.as_deref());
5568 match route_editor_lodging_anchor(lodging, &self.placed_containers) {
5569 Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
5570 None => node_candidates_stable(&self.resource_nodes),
5571 }
5572 }
5573
5574 pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
5575 if dist_m.is_nan() {
5576 return "—".into();
5577 }
5578 let from_bed = self
5579 .worker_route_editor
5580 .as_ref()
5581 .and_then(|ed| ed.lodging_container_id.as_deref())
5582 .and_then(|id| {
5583 self.placed_containers
5584 .iter()
5585 .find(|c| c.id == id)
5586 .map(|c| c.display_name.clone())
5587 });
5588 match from_bed {
5589 Some(bed) => format!("{dist_m:.0}m from {bed}"),
5590 None => format!("{dist_m:.0}m"),
5591 }
5592 }
5593
5594 pub fn placed_container_public_label(
5596 &self,
5597 c: &flatland_protocol::PlacedContainerView,
5598 ) -> String {
5599 let is_owner = match (self.character_id, c.owner_character_id) {
5600 (Some(me), Some(owner)) => me == owner,
5601 _ => false,
5602 };
5603 if is_owner {
5604 c.display_name.clone()
5605 } else {
5606 self.template_display_name(&c.template_id)
5607 }
5608 }
5609
5610 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
5612 let mut out = Vec::new();
5613 for stack in &self.inventory_stacks {
5614 if stack.template_id == KEY_TEMPLATE {
5615 out.push(KeychainEntry {
5616 stack: stack.clone(),
5617 stowed: false,
5618 });
5619 }
5620 }
5621 for stack in &self.keychain_stacks {
5622 if stack.template_id == KEY_TEMPLATE {
5623 out.push(KeychainEntry {
5624 stack: stack.clone(),
5625 stowed: true,
5626 });
5627 }
5628 }
5629 out
5630 }
5631
5632 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
5634 if stack.template_id != KEY_TEMPLATE {
5635 return None;
5636 }
5637 if let Some(name) = stack
5638 .props
5639 .get(PROP_OPENS_CONTAINER_NAME)
5640 .filter(|n| !n.is_empty())
5641 {
5642 return Some(name.clone());
5643 }
5644 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
5645 self.container_name_for_lock_id(opens)
5646 }
5647
5648 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
5650 if stack.template_id == KEY_TEMPLATE {
5651 self.template_display_name(KEY_TEMPLATE)
5652 } else {
5653 stack
5654 .display_name
5655 .clone()
5656 .unwrap_or_else(|| stack.template_id.clone())
5657 }
5658 }
5659
5660 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
5662 if stack.template_id != KEY_TEMPLATE {
5663 return String::new();
5664 }
5665 match self.key_pair_chest_label(stack) {
5666 Some(chest) if self.key_drop_blocked(stack) => {
5667 format!(" [key for {chest} — can't drop while locked]")
5668 }
5669 Some(chest) => format!(" [key for {chest}]"),
5670 None => " [key — unpaired]".into(),
5671 }
5672 }
5673
5674 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
5676 for c in &self.placed_containers {
5677 if c.lock_id.as_deref() == Some(lock) {
5678 return Some(c.display_name.clone());
5679 }
5680 }
5681 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
5682 self.worn
5683 .values()
5684 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
5685 })
5686 }
5687
5688 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
5690 if stack.template_id != KEY_TEMPLATE {
5691 return false;
5692 }
5693 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
5694 return false;
5695 };
5696 for c in &self.placed_containers {
5697 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
5698 return true;
5699 }
5700 }
5701 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
5702 return true;
5703 }
5704 self.worn
5705 .values()
5706 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
5707 }
5708
5709 pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
5711 stack.template_id == PROPERTY_DEED_TEMPLATE
5712 }
5713
5714 pub fn is_property_deed_template(template_id: &str) -> bool {
5715 template_id == PROPERTY_DEED_TEMPLATE
5716 }
5717
5718 pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
5719 stack
5720 .props
5721 .get("plot_id")
5722 .and_then(|s| uuid::Uuid::parse_str(s).ok())
5723 }
5724
5725 pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
5727 let (px, py) = self.player_position();
5728 let (cx, cy) = self.farm_plot_cell_under_player()?;
5729 let tx = cx as f32 + 0.5;
5730 let ty = cy as f32 + 0.5;
5731 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5732 return None;
5733 }
5734 let kind = self.terrain_at(tx, ty).or_else(|| self.terrain_at(px, py));
5735 if kind == Some(TerrainKindView::Tilled) {
5736 return None;
5737 }
5738 if matches!(
5739 kind,
5740 Some(TerrainKindView::ShallowWater)
5741 | Some(TerrainKindView::DeepWater)
5742 | Some(TerrainKindView::Rock)
5743 ) {
5744 return None;
5745 }
5746 Some((tx, ty))
5747 }
5748
5749 fn container_name_in_stacks(
5750 stacks: &[flatland_protocol::ItemStack],
5751 lock: &str,
5752 ) -> Option<String> {
5753 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
5754 for s in stacks {
5755 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
5756 return Some(GameState::stack_container_label(s));
5757 }
5758 if let Some(name) = walk(&s.contents, lock) {
5759 return Some(name);
5760 }
5761 }
5762 None
5763 }
5764 walk(stacks, lock)
5765 }
5766
5767 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
5768 stack
5769 .props
5770 .get(PROP_CUSTOM_NAME)
5771 .cloned()
5772 .or_else(|| stack.display_name.clone())
5773 .unwrap_or_else(|| stack.template_id.clone())
5774 }
5775
5776 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5777 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5778 for s in stacks {
5779 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
5780 return true;
5781 }
5782 if walk(&s.contents, lock) {
5783 return true;
5784 }
5785 }
5786 false
5787 }
5788 walk(stacks, lock)
5789 }
5790
5791 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
5792 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
5793 return Some(stack.clone());
5794 }
5795 for worn in self.worn.values() {
5796 if worn.item_instance_id == Some(instance_id) {
5797 return Some(worn.clone());
5798 }
5799 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
5800 return Some(stack.clone());
5801 }
5802 }
5803 None
5804 }
5805
5806 pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
5808 self.property_zones
5809 .iter()
5810 .enumerate()
5811 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5812 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5813 .map(|(_, z)| z)
5814 }
5815
5816 pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
5818 self.tax_zones
5819 .iter()
5820 .enumerate()
5821 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5822 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5823 .map(|(_, z)| z)
5824 }
5825
5826 pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
5828 let mut max_bps = 0u32;
5829 let mut y = y0 + 0.5;
5830 while y < y1 {
5831 let mut x = x0 + 0.5;
5832 while x < x1 {
5833 if let Some(tz) = self.tax_zone_at(x, y) {
5834 max_bps = max_bps.max(tz.rate_bps);
5835 }
5836 x += 1.0;
5837 }
5838 y += 1.0;
5839 }
5840 max_bps
5841 }
5842
5843 pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5845 let mode = self.claim_mode.as_ref()?;
5846 let w = mode.width_m.max(1) as f32;
5847 let h = mode.height_m.max(1) as f32;
5848 Some((
5849 mode.anchor_x,
5850 mode.anchor_y,
5851 mode.anchor_x + w,
5852 mode.anchor_y + h,
5853 ))
5854 }
5855
5856 pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5858 let mode = self.relocate_mode.as_ref()?;
5859 let x0 = mode.cursor_x.floor();
5860 let y0 = mode.cursor_y.floor();
5861 Some((x0, y0, x0 + 1.0, y0 + 1.0))
5862 }
5863
5864 pub fn claim_quote(&self) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
5867 let mode = self.claim_mode.as_ref()?;
5868 let zone = self.property_zones.iter().find(|z| z.id == mode.zone_id)?;
5869 let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
5870 let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
5871 let zone_area = zone_view_area_m2(zone).max(1.0);
5872 let area_frac = (area / zone_area).clamp(0.0, 1.0);
5873 let weight = self
5874 .property_plot_settings
5875 .as_ref()
5876 .map(|s| s.tax_premium_weight)
5877 .unwrap_or(0.5)
5878 .max(0.0);
5879 let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
5880 let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
5881 let purchase = ((zone.crown_price_copper as f64) * (area_frac as f64) * (premium as f64))
5882 .ceil()
5883 .max(0.0) as u64;
5884 let upkeep = if zone.upkeep_copper_per_day == 0 {
5885 0
5886 } else {
5887 ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
5888 .ceil()
5889 .max(1.0) as u64
5890 };
5891 let copper = crate::currency::copper_from_counts(&self.inventory);
5892 let can_afford = copper >= purchase;
5893 let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
5894 Some((purchase, upkeep, area, premium, can_afford, valid, reason))
5895 }
5896
5897 fn validate_claim_footprint(
5898 &self,
5899 zone: &flatland_protocol::PropertyZoneView,
5900 x0: f32,
5901 y0: f32,
5902 x1: f32,
5903 y1: f32,
5904 area: f32,
5905 ) -> (bool, String) {
5906 let min_area = self
5907 .property_plot_settings
5908 .as_ref()
5909 .map(|s| s.min_plot_area_m2)
5910 .unwrap_or(4.0);
5911 if area + f32::EPSILON < min_area {
5912 return (false, "plot too small".into());
5913 }
5914 if zone.max_area_m2.is_some_and(|m| area > m) {
5915 return (false, "plot exceeds max area".into());
5916 }
5917 if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
5918 return (false, "plot must lie inside the property zone".into());
5919 }
5920 if self
5921 .property_plots
5922 .iter()
5923 .any(|p| rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1))
5924 {
5925 return (false, "plot overlaps an existing claim".into());
5926 }
5927 (true, String::new())
5928 }
5929
5930 pub fn free_property_zone_under_player(&self) -> Option<&flatland_protocol::PropertyZoneView> {
5932 let (px, py) = self.player_position();
5933 let zone = self.property_zone_at(px, py)?;
5934 if self.property_plots.iter().any(|p| point_in_plot(px, py, p)) {
5935 return None;
5936 }
5937 Some(zone)
5938 }
5939
5940 pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5942 let (px, py) = self.player_position();
5943 self.property_plots
5944 .iter()
5945 .find(|p| p.is_mine && point_in_plot(px, py, p))
5946 }
5947
5948 pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5950 let (px, py) = self.player_position();
5951 self.property_plots
5952 .iter()
5953 .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
5954 }
5955
5956 pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
5958 if self.farmable_plot_under_player().is_none() {
5959 return None;
5960 }
5961 let (px, py) = self.player_position();
5962 Some((px.floor() as i32, py.floor() as i32))
5963 }
5964
5965 fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
5966 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5967 self.resource_nodes.iter().any(|n| {
5968 let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
5969 ncx == cx && ncy == cy || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
5970 })
5971 }
5972
5973 fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
5974 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5975 let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
5976 || self
5977 .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
5978 .is_some_and(|z| z.kind == TerrainKindView::Tilled);
5979 if !tilled {
5980 return false;
5981 }
5982 !self.resource_node_occupies_farm_cell(cx, cy)
5983 }
5984
5985 pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
5987 let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
5988 return false;
5989 };
5990 self.free_tilled_plant_slot_at(cx, cy)
5991 }
5992
5993 pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
5995 let (px, py) = self.player_position();
5996 for dy in -2..=2 {
5997 for dx in -2..=2 {
5998 let cx = px.floor() as i32 + dx;
5999 let cy = py.floor() as i32 + dy;
6000 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6001 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6002 continue;
6003 }
6004 if self.free_tilled_plant_slot_at(cx, cy) {
6005 return true;
6006 }
6007 }
6008 }
6009 false
6010 }
6011
6012 fn stack_is_farm_seed(stack: &flatland_protocol::ItemStack) -> bool {
6013 stack.quantity > 0
6014 && (stack.props.contains_key("seed_for")
6015 || stack.template_id.ends_with("_seed")
6016 || stack.template_id == "potato_seed"
6017 || stack.template_id == "carrot_seed")
6018 }
6019
6020 pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
6022 let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
6023 fn walk(
6024 stacks: &[flatland_protocol::ItemStack],
6025 counts: &mut std::collections::HashMap<String, u32>,
6026 ) {
6027 for s in stacks {
6028 if GameState::stack_is_farm_seed(s) {
6029 *counts.entry(s.template_id.clone()).or_default() += s.quantity;
6030 }
6031 walk(&s.contents, counts);
6032 }
6033 }
6034 walk(&self.inventory_stacks, &mut counts);
6035 for worn in self.worn.values() {
6036 walk(std::slice::from_ref(worn), &mut counts);
6037 }
6038 let mut out: Vec<_> = counts
6039 .into_iter()
6040 .map(|(template_id, quantity)| {
6041 let label = self
6042 .inventory_hints
6043 .get(&template_id)
6044 .map(|h| h.display_name.clone())
6045 .filter(|n| !n.trim().is_empty())
6046 .unwrap_or_else(|| humanize_template_id(&template_id));
6047 (template_id, quantity, label)
6048 })
6049 .collect();
6050 out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
6051 out
6052 }
6053
6054 pub fn first_farm_seed_template(&self) -> Option<String> {
6056 self.farm_seed_entries()
6057 .into_iter()
6058 .next()
6059 .map(|(id, _, _)| id)
6060 }
6061
6062 pub fn clamp_plant_menu(&mut self) {
6063 let n = self.farm_seed_entries().len();
6064 if n == 0 {
6065 self.plant_menu_index = 0;
6066 self.plant_quantity = 1;
6067 return;
6068 }
6069 self.plant_menu_index = self.plant_menu_index.min(n - 1);
6070 let max_qty = self
6071 .farm_seed_entries()
6072 .get(self.plant_menu_index)
6073 .map(|(_, q, _)| *q)
6074 .unwrap_or(1)
6075 .max(1);
6076 self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
6077 }
6078
6079 pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
6080 let entries = self.farm_seed_entries();
6081 let (id, max, label) = entries.get(self.plant_menu_index)?;
6082 let qty = self.plant_quantity.min(*max).max(1);
6083 Some((id.clone(), qty, label.clone()))
6084 }
6085
6086 pub fn location_context_lines(&self) -> Vec<ContextLine> {
6088 let (px, py) = self.player_position();
6089 let inside = self.effective_inside_building();
6090 let mut lines = Vec::new();
6091
6092 if let Some(kind) = self.terrain_at(px, py) {
6093 lines.push(ContextLine {
6094 on_top: true,
6095 text: format!("Terrain: {}", terrain_kind_label(kind)),
6096 });
6097 }
6098
6099 if let Some(id) = inside.as_ref() {
6100 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
6101 lines.push(ContextLine {
6102 on_top: true,
6103 text: format!("Inside: {}", b.label),
6104 });
6105 }
6106 }
6107
6108 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
6109
6110 for node in &self.resource_nodes {
6111 if node.id.starts_with("preview:") {
6112 continue;
6113 }
6114 let dist = distance(px, py, node.x, node.y);
6115 if dist > NEARBY_SCAN_M {
6116 continue;
6117 }
6118 let on_top = dist <= ON_TOP_RADIUS_M;
6119 let prefix = if on_top { "On" } else { "Near" };
6120 let name = resource_node_near_display_label(&node.label);
6121 let action = resource_node_near_action_suffix(node);
6122 nearby.push((
6123 dist,
6124 ContextLine {
6125 on_top,
6126 text: format!("{prefix}: {name} ({dist:.1}m){action}"),
6127 },
6128 ));
6129 }
6130
6131 for drop in &self.ground_drops {
6132 let dist = distance(px, py, drop.x, drop.y);
6133 if dist > INTERACTION_RADIUS_M {
6134 continue;
6135 }
6136 let on_top = dist <= ON_TOP_RADIUS_M;
6137 let name = self.template_display_name(&drop.template_id);
6138 let prefix = if on_top { "On" } else { "Near" };
6139 let qty = if drop.quantity > 1 {
6140 format!(" ×{}", drop.quantity)
6141 } else {
6142 String::new()
6143 };
6144 nearby.push((
6145 dist,
6146 ContextLine {
6147 on_top,
6148 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
6149 },
6150 ));
6151 }
6152
6153 for c in &self.placed_containers {
6154 if !self.placed_container_in_current_space(c) {
6155 continue;
6156 }
6157 let dist = distance(px, py, c.x, c.y);
6158 if dist > CONTAINER_RANGE_M {
6159 continue;
6160 }
6161 let on_top = dist <= ON_TOP_RADIUS_M;
6162 let name = self.placed_container_public_label(c);
6163 let lock = if c.locked { " [locked]" } else { "" };
6164 let prefix = if on_top { "On" } else { "Near" };
6165 nearby.push((
6166 dist,
6167 ContextLine {
6168 on_top,
6169 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
6170 },
6171 ));
6172 }
6173
6174 for npc in &self.npcs {
6175 let dist = distance(px, py, npc.x, npc.y);
6176 if dist > NEARBY_SCAN_M {
6177 continue;
6178 }
6179 let on_top = dist <= ON_TOP_RADIUS_M;
6180 let prefix = if on_top { "On" } else { "Near" };
6181 nearby.push((
6182 dist,
6183 ContextLine {
6184 on_top,
6185 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
6186 },
6187 ));
6188 }
6189
6190 for door in &self.doors {
6191 let dist = distance(px, py, door.x, door.y);
6192 if dist > DOOR_INTERACTION_RADIUS_M {
6193 continue;
6194 }
6195 let building = self
6196 .buildings
6197 .iter()
6198 .find(|b| b.id == door.building_id)
6199 .map(|b| b.label.as_str())
6200 .unwrap_or(door.building_id.as_str());
6201 let player_house = self
6202 .buildings
6203 .iter()
6204 .find(|b| b.id == door.building_id)
6205 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6206 let action = if inside.is_some() && door.portal.is_some() {
6207 if player_house {
6208 if door.locked {
6209 "locked — l unlock · Enter exit".to_string()
6210 } else if door.open {
6211 "close · Enter exit · l lock".to_string()
6212 } else {
6213 "open · Enter exit · l lock".to_string()
6214 }
6215 } else {
6216 "exit".to_string()
6217 }
6218 } else if player_house {
6219 if door.locked {
6220 "locked — l unlock".to_string()
6221 } else if door.open {
6222 "close · Enter go inside · l lock".to_string()
6223 } else {
6224 "open · l lock".to_string()
6225 }
6226 } else {
6227 "enter".to_string()
6228 };
6229 nearby.push((
6230 dist,
6231 ContextLine {
6232 on_top: dist <= ON_TOP_RADIUS_M,
6233 text: format!("{building} door ({dist:.1}m) — f {action}"),
6234 },
6235 ));
6236 }
6237
6238 if inside.is_none() {
6239 for inter in &self.interactables {
6240 if inter.kind != "quest_board" {
6241 continue;
6242 }
6243 let dist = distance(px, py, inter.x, inter.y);
6244 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
6245 continue;
6246 }
6247 let on_top = dist <= ON_TOP_RADIUS_M;
6248 let prefix = if on_top { "On" } else { "Near" };
6249 let label = if inter.label.is_empty() {
6250 "Quest board".to_string()
6251 } else {
6252 inter.label.clone()
6253 };
6254 nearby.push((
6255 dist,
6256 ContextLine {
6257 on_top,
6258 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
6259 },
6260 ));
6261 }
6262 }
6263
6264 if self.in_shallow_water() {
6265 let already = self
6266 .terrain_at(px, py)
6267 .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
6268 if !already {
6269 nearby.push((
6270 0.0,
6271 ContextLine {
6272 on_top: true,
6273 text: "Shallow water — f fill bottle".into(),
6274 },
6275 ));
6276 } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
6277 line.text.push_str(" — f fill bottle");
6278 }
6279 }
6280
6281 if self.claim_mode.is_some() {
6282 nearby.push((
6283 0.0,
6284 ContextLine {
6285 on_top: true,
6286 text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
6287 .into(),
6288 },
6289 ));
6290 } else if let Some(plot) = self.my_plot_under_player() {
6291 let name = plot_public_label(plot);
6292 let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
6293 format!("{name} — f again to sell to crown")
6294 } else {
6295 format!(
6296 "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
6297 )
6298 };
6299 nearby.push((
6300 0.0,
6301 ContextLine {
6302 on_top: true,
6303 text: prompt,
6304 },
6305 ));
6306 } else if let Some(plot) = self.farmable_plot_under_player() {
6307 let name = plot_public_label(plot);
6308 let disc = if plot.farm_public {
6309 plot.public_tax_discount_bps / 100
6310 } else {
6311 plot.farm_allow
6312 .iter()
6313 .find(|g| Some(g.character_id) == self.character_id)
6314 .map(|g| g.tax_discount_bps / 100)
6315 .unwrap_or(0)
6316 };
6317 nearby.push((
6318 0.0,
6319 ContextLine {
6320 on_top: true,
6321 text: format!(
6322 "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
6323 ),
6324 },
6325 ));
6326 } else if let Some(zone) = self.free_property_zone_under_player() {
6327 let label = zone
6328 .label
6329 .as_deref()
6330 .filter(|s| !s.trim().is_empty())
6331 .unwrap_or(zone.id.as_str());
6332 nearby.push((
6333 0.0,
6334 ContextLine {
6335 on_top: true,
6336 text: format!("Claimable land: {label} — k buy plot"),
6337 },
6338 ));
6339 }
6340
6341 for entity in &self.entities {
6342 if entity.id == self.entity_id {
6343 continue;
6344 }
6345 let dist = distance(
6346 px,
6347 py,
6348 entity.transform.position.x,
6349 entity.transform.position.y,
6350 );
6351 if dist > NEARBY_SCAN_M {
6352 continue;
6353 }
6354 let label = if entity.label.is_empty() {
6355 format!("entity {}", entity.id)
6356 } else {
6357 entity.label.clone()
6358 };
6359 nearby.push((
6360 dist,
6361 ContextLine {
6362 on_top: dist <= ON_TOP_RADIUS_M,
6363 text: format!("Near: {label} ({dist:.1}m)"),
6364 },
6365 ));
6366 }
6367
6368 nearby.sort_by(|a, b| {
6369 a.0.partial_cmp(&b.0)
6370 .unwrap_or(std::cmp::Ordering::Equal)
6371 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
6372 });
6373 lines.extend(nearby.into_iter().map(|(_, l)| l));
6374
6375 if lines.is_empty() {
6376 lines.push(ContextLine {
6377 on_top: false,
6378 text: "(nothing notable nearby)".into(),
6379 });
6380 }
6381
6382 lines
6383 }
6384}
6385
6386#[derive(Debug, Clone)]
6388pub struct ContextLine {
6389 pub on_top: bool,
6390 pub text: String,
6391}
6392
6393const ON_TOP_RADIUS_M: f32 = 0.65;
6394const NEARBY_SCAN_M: f32 = 5.0;
6395
6396pub fn resource_node_near_display_label(label: &str) -> String {
6398 label
6399 .strip_suffix(" (growing)")
6400 .unwrap_or(label)
6401 .to_string()
6402}
6403
6404fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
6405 let t = label.trim();
6406 if t.is_empty() || t == id {
6407 return true;
6408 }
6409 let lower = t.to_ascii_lowercase();
6410 if lower.contains("_copy") {
6411 return true;
6412 }
6413 false
6414}
6415
6416fn humanize_item_template_label(template: &str) -> String {
6417 let base = template.rsplit('/').next().unwrap_or(template).trim();
6418 if base.is_empty() {
6419 return "Resource".into();
6420 }
6421 let stripped = base
6422 .strip_prefix("crop-")
6423 .or_else(|| base.strip_prefix("crop_"))
6424 .unwrap_or(base);
6425 stripped
6426 .split(|c: char| c == '-' || c == '_')
6427 .filter(|p| !p.is_empty())
6428 .map(|p| {
6429 let mut chars = p.chars();
6430 match chars.next() {
6431 Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
6432 None => String::new(),
6433 }
6434 })
6435 .collect::<Vec<_>>()
6436 .join(" ")
6437}
6438
6439pub fn resource_node_id_suffix(id: &str) -> String {
6441 let chars: Vec<char> = id
6442 .chars()
6443 .rev()
6444 .filter(|c| c.is_ascii_alphanumeric())
6445 .take(4)
6446 .collect();
6447 chars.into_iter().rev().collect()
6448}
6449
6450pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
6452 resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
6453}
6454
6455pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
6456 let cleaned = resource_node_near_display_label(label);
6457 let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
6458 cleaned
6459 } else if !item_template.trim().is_empty() {
6460 humanize_item_template_label(item_template)
6461 } else {
6462 id.to_string()
6463 };
6464 let suffix = resource_node_id_suffix(id);
6465 if suffix.is_empty() {
6466 friendly
6467 } else {
6468 format!("{friendly} ({suffix})")
6469 }
6470}
6471
6472pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
6474 use flatland_protocol::ResourceNodeState;
6475 if node.harvest_off {
6476 return " (decorative)".to_string();
6477 }
6478 if let Some(p) = node.growth_progress {
6479 if p < 1.0 - f32::EPSILON {
6480 let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
6481 return format!(" (growing, {pct}%)");
6482 }
6483 return " — f harvest".to_string();
6484 }
6485 match node.state {
6486 ResourceNodeState::Available => " — f harvest".to_string(),
6487 ResourceNodeState::Harvesting => " (being harvested)".to_string(),
6488 ResourceNodeState::Cooldown => " (depleted)".to_string(),
6489 }
6490}
6491
6492fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
6493 use flatland_protocol::TerrainKindView;
6494 match kind {
6495 TerrainKindView::Grass => "Grass",
6496 TerrainKindView::Dirt => "Dirt",
6497 TerrainKindView::Tilled => "Tilled",
6498 TerrainKindView::Desert => "Desert",
6499 TerrainKindView::Hill => "Hills",
6500 TerrainKindView::Bog => "Bog",
6501 TerrainKindView::Beach => "Beach",
6502 TerrainKindView::ShallowWater => "Shallow water",
6503 TerrainKindView::DeepWater => "Deep water",
6504 TerrainKindView::Trail => "Trail",
6505 TerrainKindView::Road => "Road",
6506 TerrainKindView::Rock => "Rock",
6507 }
6508}
6509
6510fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
6511 crate::world_zones::zone_rects_contain(rects, x, y)
6512}
6513
6514fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
6515 zone.rects
6516 .iter()
6517 .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
6518 .sum()
6519}
6520
6521fn claim_rect_fully_inside_zone(
6522 zone: &flatland_protocol::PropertyZoneView,
6523 x0: f32,
6524 y0: f32,
6525 x1: f32,
6526 y1: f32,
6527) -> bool {
6528 let mut y = y0 + 0.5;
6529 while y < y1 {
6530 let mut x = x0 + 0.5;
6531 while x < x1 {
6532 if !zone_rects_contain(&zone.rects, x, y) {
6533 return false;
6534 }
6535 x += 1.0;
6536 }
6537 y += 1.0;
6538 }
6539 true
6540}
6541
6542fn rects_overlap_half_open(
6543 ax0: f32,
6544 ay0: f32,
6545 ax1: f32,
6546 ay1: f32,
6547 bx0: f32,
6548 by0: f32,
6549 bx1: f32,
6550 by1: f32,
6551) -> bool {
6552 ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
6553}
6554
6555fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
6556 x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
6557}
6558
6559fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
6560 plot_public_label(p)
6561}
6562
6563pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
6565 let zone = p
6566 .zone_label
6567 .as_deref()
6568 .filter(|s| !s.trim().is_empty())
6569 .unwrap_or_else(|| {
6570 if p.property_zone_id.is_empty() {
6571 "Homestead"
6572 } else {
6573 p.property_zone_id.as_str()
6574 }
6575 });
6576 let label = if p.label.trim().is_empty() {
6577 if p.plot_code.trim().is_empty() {
6578 p.plot_id.to_string()[..8.min(p.plot_id.to_string().len())].to_string()
6579 } else {
6580 p.plot_code.clone()
6581 }
6582 } else {
6583 p.label.clone()
6584 };
6585 match p
6586 .owner_label
6587 .as_deref()
6588 .map(str::trim)
6589 .filter(|s| !s.is_empty())
6590 {
6591 Some(owner) => format!("{owner} — {zone} — {label}"),
6592 None => format!("{zone} — {label}"),
6593 }
6594}
6595
6596fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
6598 let a = x0.min(x1).floor();
6599 let b = y0.min(y1).floor();
6600 let mut c = x0.max(x1).ceil();
6601 let mut d = y0.max(y1).ceil();
6602 if (c - a) < 1.0 {
6603 c = a + 1.0;
6604 }
6605 if (d - b) < 1.0 {
6606 d = b + 1.0;
6607 }
6608 (a, b, c, d)
6609}
6610
6611fn humanize_template_id(template_id: &str) -> String {
6612 if looks_like_template_uuid(template_id) {
6614 return "Unknown item".into();
6615 }
6616 template_id
6617 .split('_')
6618 .map(|word| {
6619 let mut chars = word.chars();
6620 match chars.next() {
6621 None => String::new(),
6622 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
6623 }
6624 })
6625 .collect::<Vec<_>>()
6626 .join(" ")
6627}
6628
6629fn looks_like_template_uuid(template_id: &str) -> bool {
6630 let bytes = template_id.as_bytes();
6631 if bytes.len() != 36 {
6632 return false;
6633 }
6634 let is_hex = |b: u8| b.is_ascii_hexdigit();
6635 let groups = [8usize, 4, 4, 4, 12];
6636 let mut i = 0;
6637 for (gi, &len) in groups.iter().enumerate() {
6638 if gi > 0 {
6639 if bytes.get(i) != Some(&b'-') {
6640 return false;
6641 }
6642 i += 1;
6643 }
6644 for _ in 0..len {
6645 if !bytes.get(i).copied().is_some_and(is_hex) {
6646 return false;
6647 }
6648 i += 1;
6649 }
6650 }
6651 true
6652}
6653
6654const HARVEST_RANGE_M: f32 = 1.5;
6656
6657pub struct GameClient<S: PlayConnection> {
6658 session: S,
6659 seq: Seq,
6660 pub state: GameState,
6661 last_move_forward: f32,
6662 last_move_strafe: f32,
6663}
6664
6665impl<S: PlayConnection> GameClient<S> {
6666 pub fn new(session: S) -> Self {
6667 let session_id = session.session_id();
6668 let entity_id = session.entity_id();
6669 let mut client = Self {
6670 session,
6671 seq: 0,
6672 last_move_forward: 0.0,
6673 last_move_strafe: 0.0,
6674 state: GameState {
6675 session_id,
6676 entity_id,
6677 character_id: None,
6678 tick: 0,
6679 chunk_rev: 0,
6680 content_rev: 0,
6681 publish_rev: 0,
6682 entities: Vec::new(),
6683 player: None,
6684 resource_nodes: Vec::new(),
6685 ground_drops: Vec::new(),
6686 placed_containers: Vec::new(),
6687 buildings: Vec::new(),
6688 doors: Vec::new(),
6689 interior_map: None,
6690 npcs: Vec::new(),
6691 blueprints: Vec::new(),
6692 building_materials: Vec::new(),
6693 world_x0: 0.0,
6694 world_y0: 0.0,
6695 world_width_m: 0.0,
6696 world_height_m: 0.0,
6697 terrain_zones: Vec::new(),
6698 z_platforms: Vec::new(),
6699 z_transitions: Vec::new(),
6700 z_bands_outdoor_backup: None,
6701 world_clock: flatland_protocol::WorldClock::default(),
6702 inventory: std::collections::HashMap::new(),
6703 inventory_hints: std::collections::HashMap::new(),
6704 logs: VecDeque::new(),
6705 intents_sent: 0,
6706 ticks_received: 0,
6707 connected: false,
6708 disconnect_reason: None,
6709 show_stats: false,
6710 hud_log_hidden: false,
6711 show_equip_menu: false,
6712 equip_menu_index: 0,
6713 show_craft_menu: false,
6714 show_plot_build_menu: false,
6715 plot_build_focus_wall: true,
6716 plot_build_wall_index: 0,
6717 plot_build_roof_index: 0,
6718 craft_menu_index: 0,
6719 craft_batch_quantity: 1,
6720 show_shop_menu: false,
6721 shop_catalog: None,
6722 bank_panel: None,
6723 bank_menu_index: 0,
6724 bank_ui_mode: BankUiMode::Menu,
6725 storage_panel: None,
6726 market_panel: None,
6727 market_menu_index: 0,
6728 market_filter: String::new(),
6729 market_filter_focused: false,
6730 market_category_filter: None,
6731 market_buy_confirm: None,
6732 market_ui_mode: MarketUiMode::Browse,
6733 storage_menu_index: 0,
6734 storage_ui_mode: StorageUiMode::Menu,
6735 shop_tab: ShopTab::default(),
6736 shop_menu_index: 0,
6737 shop_quantity: 1,
6738 shop_trade_log: VecDeque::new(),
6739 show_npc_verb_menu: false,
6740 npc_verb_target: None,
6741 npc_verb_index: 0,
6742 player_verbs: crate::social::PlayerVerbState::default(),
6743 social_chat: crate::social::SocialChatState::default(),
6744 trade_ui: crate::social::TradeUiState::default(),
6745 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
6746 show_npc_chat: false,
6747 npc_chat: None,
6748 show_inventory_menu: false,
6749 inventory_menu_index: 0,
6750 inventory_tab: InventoryTab::OnPerson,
6751 inventory_filter: String::new(),
6752 inventory_filter_focused: false,
6753 show_move_picker: false,
6754 show_rename_prompt: false,
6755 rename_plot_id: None,
6756 highlighted_plot_id: None,
6757 show_worker_rename: false,
6758 rename_buffer: String::new(),
6759 move_picker_index: 0,
6760 move_picker: None,
6761 show_grant_picker: false,
6762 grant_picker_index: 0,
6763 grant_picker: None,
6764 show_destroy_picker: false,
6765 destroy_confirm_pending: false,
6766 destroy_picker: None,
6767 combat_target: None,
6768 combat_target_label: None,
6769 ground_target: None,
6770 combat_fx: Vec::new(),
6771 ground_hazards: Vec::new(),
6772 property_zones: Vec::new(),
6773 tax_zones: Vec::new(),
6774 growth_zones: Vec::new(),
6775 biome_zones: Vec::new(),
6776 terrain_kind_nav: Vec::new(),
6777 property_plots: Vec::new(),
6778 property_plot_settings: None,
6779 claim_mode: None,
6780 relocate_mode: None,
6781 sell_plot_confirm: None,
6782 sell_plot_armed_at: None,
6783 show_plant_menu: false,
6784 plant_menu_index: 0,
6785 show_farm_access: false,
6786 farm_access_name_draft: String::new(),
6787 farm_access_discount_bps: 0,
6788 farm_access_index: 0,
6789 plant_quantity: 1,
6790 in_combat: false,
6791 auto_attack: true,
6792 combat_has_los: false,
6793 attack_cd_ticks: 0,
6794 gcd_ticks: 0,
6795 weapon_ability_id: "unarmed".into(),
6796 mainhand_template_id: None,
6797 mainhand_label: None,
6798 mainhand_instance_id: None,
6799 offhand_template_id: None,
6800 offhand_label: None,
6801 offhand_instance_id: None,
6802 mainhand_hand_slots: 1,
6803 defense: None,
6804 worn: BTreeMap::new(),
6805 carry_mass: 0.0,
6806 carry_mass_max: 0.0,
6807 encumbrance: flatland_protocol::EncumbranceState::Light,
6808 inventory_stacks: Vec::new(),
6809 keychain_stacks: Vec::new(),
6810 whisper_pouch_stacks: Vec::new(),
6811 combat_target_detail: None,
6812 statuses: Vec::new(),
6813 cast_progress: None,
6814 timed_channel: None,
6815 plot_build_offer: None,
6816 ability_cooldowns: Vec::new(),
6817 blocking_active: false,
6818 max_target_slots: 1,
6819 combat_slots: Vec::new(),
6820 rotation_presets: Vec::new(),
6821 known_abilities: Vec::new(),
6822 ability_meta: std::collections::HashMap::new(),
6823 ability_mastery: std::collections::HashMap::new(),
6824 hotbar: vec![None; 9],
6825 max_abilities_per_rotation: 0,
6826 show_loadout_menu: false,
6827 show_keychain_menu: false,
6828 keychain_menu_index: 0,
6829 show_rotation_editor: false,
6830 loadout_menu_index: 0,
6831 loadout_hotbar_slot: 1,
6832 loadout_ability_index: 0,
6833 loadout_focus_presets: false,
6834 rotation_editor: RotationEditorState::default(),
6835 harvest_in_progress: false,
6836 harvest_started_at: None,
6837 pending_craft_ack: None,
6838 pending_worker_job_ack: None,
6839 attending_worker_instance_id: None,
6840 quest_log: Vec::new(),
6841 interactables: Vec::new(),
6842 ledger: None,
6843 career: None,
6844 character_sheet_tab: CharacterSheetTab::Character,
6845 ledger_period: LedgerPeriod::Day,
6846 show_quest_offer: false,
6847 pending_quest_offer: None,
6848 show_quest_menu: false,
6849 quest_menu_index: 0,
6850 quest_withdraw_confirm: false,
6851 hired_workers: Vec::new(),
6852 show_workers_menu: false,
6853 workers_menu_index: 0,
6854 worker_dismiss_confirmation: None,
6855 workers_menu_compact: false,
6856 worker_step_display: BTreeMap::new(),
6857 worker_error_display: BTreeMap::new(),
6858 worker_health_ring_until: BTreeMap::new(),
6859 show_worker_give_picker: false,
6860 worker_give_picker_index: 0,
6861 worker_give_picker: None,
6862 show_worker_give_target_picker: false,
6863 worker_give_target_picker_index: 0,
6864 worker_give_target_picker: None,
6865 show_worker_take_picker: false,
6866 worker_take_picker_index: 0,
6867 worker_take_picker: None,
6868 show_worker_teach_picker: false,
6869 worker_teach_picker_index: 0,
6870 worker_teach_picker: None,
6871 worker_route_editor: None,
6872 progression_curve: None,
6873 },
6874 };
6875 client.state.apply_client_ui_prefs();
6876 client
6877 }
6878
6879 pub fn entity_id(&self) -> EntityId {
6880 self.state.entity_id
6881 }
6882
6883 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
6884 if self.state.connected {
6885 return Ok(());
6886 }
6887
6888 loop {
6889 match self.session.next_event().await {
6890 Some(SessionEvent::Welcome {
6891 session_id,
6892 entity_id,
6893 snapshot,
6894 }) => {
6895 self.state
6896 .restore_from_welcome(session_id, entity_id, &snapshot);
6897 self.state.apply_client_ui_prefs();
6898 self.state.push_log(format!(
6899 "Connected — session {session_id}, entity {entity_id}"
6900 ));
6901 return Ok(());
6902 }
6903 Some(SessionEvent::Disconnected { .. }) => {
6904 anyhow::bail!("disconnected before welcome");
6905 }
6906 Some(_) => continue,
6907 None => anyhow::bail!("session closed before welcome"),
6908 }
6909 }
6910 }
6911
6912 pub fn drain_events(&mut self) {
6914 while let Some(event) = self.session.try_next_event() {
6915 if self.handle_event_sync(event).is_err() {
6916 break;
6917 }
6918 }
6919 }
6920
6921 pub async fn next_event(&mut self) -> Option<SessionEvent> {
6923 self.session.next_event().await
6924 }
6925
6926 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6927 self.handle_event_sync(event)
6928 }
6929
6930 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6931 match event {
6932 SessionEvent::Welcome {
6933 session_id,
6934 entity_id,
6935 snapshot,
6936 } => {
6937 let resumed = self.state.connected;
6938 self.state
6939 .restore_from_welcome(session_id, entity_id, &snapshot);
6940 if resumed {
6941 self.state.push_log(format!(
6942 "Session restored — session {session_id}, entity {entity_id}"
6943 ));
6944 }
6945 }
6946 SessionEvent::ContentUpdated { snapshot } => {
6947 self.state
6948 .apply_snapshot_fields(&snapshot, self.state.entity_id);
6949 self.state.push_log(format!(
6950 "World updated (content rev {})",
6951 snapshot.content_rev
6952 ));
6953 }
6954 SessionEvent::QuestCatalogUpdated(update) => {
6955 self.state.push_log(format!(
6956 "Quest board updated (revision {}, {} new, {} retired)",
6957 update.revision,
6958 update.accepted.len(),
6959 update.retired.len()
6960 ));
6961 }
6962 SessionEvent::Tick(delta) => {
6963 self.state.apply_tick_fields(&delta, self.state.entity_id);
6964 self.state.ticks_received += 1;
6965 }
6966 SessionEvent::IntentAck {
6967 entity_id,
6968 seq,
6969 tick,
6970 } => {
6971 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
6972 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
6973 if *craft_seq == seq {
6974 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
6975 if batches > 1 {
6976 self.state.push_log(format!("Crafting {label} ×{batches}…"));
6977 } else {
6978 self.state.push_log(format!("Crafting {label}…"));
6979 }
6980 }
6981 }
6982 if self
6983 .state
6984 .pending_worker_job_ack
6985 .as_ref()
6986 .is_some_and(|p| p.seq == seq)
6987 {
6988 let pending = self.state.pending_worker_job_ack.take().unwrap();
6989 if pending.idle {
6990 self.state.push_log(format!(
6991 "Route cleared for {} — worker idle",
6992 pending.worker_label
6993 ));
6994 } else {
6995 self.state.push_log(format!(
6996 "Route saved for {} — {} stop(s), job loop active",
6997 pending.worker_label, pending.stop_count
6998 ));
6999 }
7000 if self
7001 .state
7002 .worker_route_editor
7003 .as_ref()
7004 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
7005 {
7006 self.close_worker_route_editor();
7007 }
7008 }
7009 }
7010 SessionEvent::Chat(msg) => {
7011 let label = match msg.channel {
7012 flatland_protocol::ChatChannel::Nearby => "nearby",
7013 flatland_protocol::ChatChannel::Direct => "speak",
7014 flatland_protocol::ChatChannel::Whisper => "whisper",
7015 flatland_protocol::ChatChannel::WhisperStone => "stone",
7016 };
7017 let clarity = match msg.clarity {
7018 flatland_protocol::ChatClarity::Clear => "",
7019 flatland_protocol::ChatClarity::Partial => "~",
7020 flatland_protocol::ChatClarity::Heavy => "…",
7021 };
7022 self.state.push_log(format!(
7023 "[{label}{clarity}] {}: {}",
7024 msg.from_name, msg.text
7025 ));
7026 let now_ms = std::time::SystemTime::now()
7027 .duration_since(std::time::UNIX_EPOCH)
7028 .map(|d| d.as_millis() as u64)
7029 .unwrap_or(0);
7030 self.state
7031 .social_chat
7032 .note_speech(&msg, self.state.entity_id, now_ms);
7033 self.state
7034 .social_chat
7035 .push(crate::social::ChatLogEntry::from_message(
7036 msg,
7037 self.state.entity_id,
7038 ));
7039 }
7040 SessionEvent::TradeOpened(panel) => {
7041 self.state.social_chat.pending_trade = None;
7042 let peer = panel.peer_name.clone();
7043 self.state.trade_ui.open(panel);
7044 self.state.social_chat.push_system(format!(
7045 "Trade open with {peer} — p present · r ready · Esc cancel"
7046 ));
7047 self.state
7048 .social_chat
7049 .push_cue(crate::social::AudioCue::TradeOpened);
7050 }
7051 SessionEvent::TradeClosed { reason } => {
7052 self.state.push_log(reason.clone());
7053 self.state.social_chat.push_system(reason);
7054 self.state.trade_ui.close();
7055 }
7056 SessionEvent::HarvestResult(result) => {
7057 self.state.clear_harvest_state();
7058 crate::harvest_trace!(
7059 entity_id = self.state.entity_id,
7060 node_id = %result.node_id,
7061 template = %result.item_template,
7062 quantity = result.quantity,
7063 client_tick = self.state.tick,
7064 "client applied harvest result"
7065 );
7066 let msg = if result.quantity == 0 {
7067 format!(
7068 "Harvested {} x0 — nothing dropped (loot table rolled empty)",
7069 result.item_template
7070 )
7071 } else {
7072 format!(
7073 "Harvested {} x{} (on the ground — press P to pick up)",
7074 result.item_template, result.quantity
7075 )
7076 };
7077 self.state.push_log(msg);
7078 }
7079 SessionEvent::CraftResult(result) => {
7080 for stack in &result.consumed {
7081 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
7082 *qty = qty.saturating_sub(stack.quantity);
7083 if *qty == 0 {
7084 self.state.inventory.remove(&stack.template_id);
7085 }
7086 }
7087 }
7088 for stack in &result.outputs {
7089 *self
7090 .state
7091 .inventory
7092 .entry(stack.template_id.clone())
7093 .or_insert(0) += stack.quantity;
7094 }
7095 if let Some(output) = result.outputs.first() {
7096 if result.batch_total > 1 {
7097 self.state.push_log(format!(
7098 "Crafted {} x{} ({}/{})",
7099 output.template_id,
7100 output.quantity,
7101 result.batch_index,
7102 result.batch_total
7103 ));
7104 } else {
7105 self.state.push_log(format!(
7106 "Crafted {} x{}",
7107 output.template_id, output.quantity
7108 ));
7109 }
7110 } else {
7111 self.state
7112 .push_log(format!("Craft finished: {}", result.blueprint_id));
7113 }
7114 }
7115 SessionEvent::Death(notice) => {
7116 self.state.clear_harvest_state();
7117 self.state.push_log(notice.message.clone());
7118 self.state.push_log(format!(
7119 "Respawned at ({:.1}, {:.1})",
7120 notice.respawn_x, notice.respawn_y
7121 ));
7122 }
7123 SessionEvent::Interaction(notice) => {
7124 if notice.message.starts_with("Harvest failed:") {
7125 self.state.clear_harvest_state();
7126 }
7127 if notice.message.starts_with("Can't do that:") {
7128 self.state.pending_craft_ack = None;
7129 if let Some(pending) = self.state.pending_worker_job_ack.take() {
7130 if let Some(w) = self
7131 .state
7132 .hired_workers
7133 .iter_mut()
7134 .find(|w| w.instance_id == pending.worker_instance_id)
7135 {
7136 w.route = pending.prev_route;
7137 w.mode = pending.prev_mode;
7138 w.step_label = pending.prev_step_label;
7139 w.last_error = pending.prev_last_error;
7140 }
7141 let reason = notice
7142 .message
7143 .strip_prefix("Can't do that:")
7144 .unwrap_or(¬ice.message)
7145 .trim();
7146 self.state.push_log(format!(
7147 "Route save failed for {}: {reason}",
7148 pending.worker_label
7149 ));
7150 }
7151 let reason = notice
7152 .message
7153 .strip_prefix("Can't do that:")
7154 .unwrap_or(¬ice.message)
7155 .trim();
7156 if reason.contains("already tilled") {
7157 if let Some(plot) = self.state.my_plot_under_player() {
7158 self.state.sell_plot_confirm = Some(plot.plot_id);
7159 self.state.sell_plot_armed_at = Some(Instant::now());
7160 }
7161 }
7162 }
7163 if notice.message.starts_with("Cast failed:") {
7164 self.state.cast_progress = None;
7165 }
7166 if notice.message.contains("slain the") {
7167 self.state.combat_target = None;
7168 self.state.combat_target_label = None;
7169 }
7170 if notice.message.contains("wants to trade") {
7172 if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
7173 let from_name = notice
7174 .message
7175 .split(" wants to trade")
7176 .next()
7177 .unwrap_or("Player")
7178 .to_string();
7179 self.state.social_chat.pending_trade =
7180 Some(crate::social::PendingTradeRequest {
7181 from_entity,
7182 from_name: from_name.clone(),
7183 });
7184 self.state.social_chat.push_system(format!(
7185 "{from_name} wants to trade — [Y] accept · [N] decline"
7186 ));
7187 self.state
7188 .social_chat
7189 .push_cue(crate::social::AudioCue::TradeOffer);
7190 }
7191 }
7192 if notice.message.starts_with("trade request declined") {
7193 self.state.social_chat.push_system(notice.message.clone());
7194 self.state
7195 .social_chat
7196 .push_cue(crate::social::AudioCue::TradeDeclined);
7197 }
7198 self.state.apply_interaction_notice(¬ice);
7199 self.state.push_log(notice.message.clone());
7200 }
7201 SessionEvent::ShopOpened(catalog) => {
7202 self.state.apply_shop_catalog(catalog);
7203 }
7204 SessionEvent::BankOpened(panel) => {
7205 self.state.apply_bank_panel(panel);
7206 }
7207 SessionEvent::StorageOpened(panel) => {
7208 self.state.apply_storage_panel(panel);
7209 }
7210 SessionEvent::MarketOpened(panel) => {
7211 self.state.apply_market_panel(panel);
7212 }
7213 SessionEvent::NpcTalkOpened(opened) => {
7214 self.state.show_npc_verb_menu = false;
7215 if self.state.npc_verb_target.is_none() {
7216 self.state.npc_verb_target = Some(opened.npc_id.clone());
7217 }
7218 let label = opened.npc_label.clone();
7219 let banner = if !opened.trade_allowed {
7220 Some("Trade is unavailable right now.".to_string())
7221 } else {
7222 None
7223 };
7224 self.state.show_npc_chat = true;
7225 self.state.npc_chat = Some(NpcChatState {
7226 npc_id: opened.npc_id,
7227 npc_label: opened.npc_label,
7228 lines: if opened.greeting.is_empty() {
7229 vec![]
7230 } else {
7231 vec![format!("{label}: {}", opened.greeting)]
7232 },
7233 input: String::new(),
7234 pending: opened.greeting.is_empty(),
7235 talk_depth: opened.talk_depth,
7236 trade_allowed: opened.trade_allowed,
7237 banner,
7238 suggested_topics: opened.suggested_topics,
7239 });
7240 }
7241 SessionEvent::NpcTalkPending(_) => {
7242 if let Some(chat) = self.state.npc_chat.as_mut() {
7243 chat.pending = true;
7244 }
7245 }
7246 SessionEvent::NpcTalkReply(reply) => {
7247 if let Some(chat) = self.state.npc_chat.as_mut() {
7248 if chat.npc_id == reply.npc_id {
7249 chat.pending = false;
7250 if reply.trade_disabled {
7251 chat.trade_allowed = false;
7252 chat.banner = Some("Trade is unavailable right now.".to_string());
7253 }
7254 if reply.wind_down {
7255 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
7256 if chat.banner.is_none() {
7257 chat.banner =
7258 Some("They're wrapping up — keep it brief.".to_string());
7259 }
7260 }
7261 chat.lines
7262 .push(format!("{}: {}", chat.npc_label, reply.line));
7263 }
7264 }
7265 }
7266 SessionEvent::NpcTalkClosed(closed) => {
7267 if self
7268 .state
7269 .npc_chat
7270 .as_ref()
7271 .is_some_and(|c| c.npc_id == closed.npc_id)
7272 {
7273 self.state.show_npc_chat = false;
7274 self.state.npc_chat = None;
7275 }
7276 }
7277 SessionEvent::NpcTalkError(err) => {
7278 self.state.push_log(format!("Talk failed: {}", err.reason));
7279 if let Some(chat) = self.state.npc_chat.as_mut() {
7280 chat.pending = false;
7281 }
7282 }
7283 SessionEvent::UseResult(result) => {
7284 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
7287 *qty = qty.saturating_sub(1);
7288 if *qty == 0 {
7289 self.state.inventory.remove(&result.template_id);
7290 }
7291 }
7292 }
7293 SessionEvent::QuestOffer(offer) => {
7294 self.state.pending_quest_offer = Some(offer.clone());
7295 self.state.show_quest_offer = true;
7296 self.state
7297 .push_log(format!("Quest offered: {}", offer.title));
7298 }
7299 SessionEvent::QuestAccepted(notice) => {
7300 self.state.show_quest_offer = false;
7301 self.state.pending_quest_offer = None;
7302 self.state.push_log(notice.message);
7303 }
7304 SessionEvent::QuestWithdrawn(notice) => {
7305 self.state.show_quest_menu = false;
7306 self.state.quest_withdraw_confirm = false;
7307 self.state.push_log(notice.message);
7308 }
7309 SessionEvent::QuestStepCompleted(notice) => {
7310 self.state.push_log(notice.message);
7311 }
7312 SessionEvent::QuestCompleted(notice) => {
7313 self.state.push_log(notice.message);
7314 }
7315 SessionEvent::Disconnected { reason } => {
7316 self.state.clear_harvest_state();
7317 self.state.connected = false;
7318 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
7319 if let Some(r) = &self.state.disconnect_reason {
7320 self.state.push_log(format!("Disconnected: {r}"));
7321 } else {
7322 self.state.push_log("Disconnected from server");
7323 }
7324 }
7325 }
7326 Ok(())
7327 }
7328
7329 pub fn is_connected(&self) -> bool {
7330 self.state.connected
7331 }
7332
7333 pub fn close_overlays(&mut self) {
7334 self.state.show_stats = false;
7335 self.state.show_craft_menu = false;
7336 self.state.show_plot_build_menu = false;
7337 self.state.show_shop_menu = false;
7338 self.state.shop_catalog = None;
7339 self.state.show_npc_verb_menu = false;
7340 self.state.npc_verb_target = None;
7341 self.state.show_npc_chat = false;
7342 self.state.npc_chat = None;
7343 self.state.show_inventory_menu = false;
7344 self.state.show_loadout_menu = false;
7345 self.state.show_rotation_editor = false;
7346 self.state.rotation_editor.reset();
7347 self.state.show_rename_prompt = false;
7348 self.state.show_worker_rename = false;
7349 self.state.rename_buffer.clear();
7350 self.state.show_move_picker = false;
7351 self.state.move_picker = None;
7352 self.state.show_destroy_picker = false;
7353 self.state.destroy_confirm_pending = false;
7354 self.state.destroy_picker = None;
7355 self.state.show_quest_offer = false;
7356 self.state.pending_quest_offer = None;
7357 self.state.show_quest_menu = false;
7358 self.state.quest_withdraw_confirm = false;
7359 self.state.show_workers_menu = false;
7360 self.close_worker_give_picker();
7361 self.close_worker_give_target_picker();
7362 self.close_worker_take_picker();
7363 self.close_worker_teach_picker();
7364 self.state.worker_route_editor = None;
7365 self.state.claim_mode = None;
7366 self.state.relocate_mode = None;
7367 self.state.sell_plot_confirm = None;
7368 self.state.sell_plot_armed_at = None;
7369 self.close_farm_access_panel();
7370 if self.state.show_plant_menu {
7371 self.close_plant_menu();
7372 }
7373 }
7374
7375 pub fn back_on_esc(&mut self) -> bool {
7377 if self.state.social_chat.composer_open() {
7378 self.state.social_chat.close_composer();
7379 return true;
7380 }
7381 if self.state.player_verbs.open {
7382 self.state.player_verbs.close();
7383 return true;
7384 }
7385 if self.state.whisper_pouch_ui.open {
7386 self.state.whisper_pouch_ui.open = false;
7387 return true;
7388 }
7389 if self.state.trade_ui.panel.is_some() {
7390 self.state.trade_ui.close();
7392 return true;
7393 }
7394 if self.state.show_rename_prompt {
7395 self.cancel_rename_prompt();
7396 return true;
7397 }
7398 if self.state.show_worker_rename {
7399 self.cancel_worker_rename();
7400 return true;
7401 }
7402 if self.state.show_destroy_picker {
7403 if self.state.destroy_confirm_pending {
7404 self.cancel_destroy_confirm();
7405 } else {
7406 self.close_destroy_picker();
7407 }
7408 return true;
7409 }
7410 if self.state.claim_mode.is_some() {
7411 self.cancel_claim_mode();
7412 return true;
7413 }
7414 if self.state.relocate_mode.is_some() {
7415 self.cancel_relocate_mode();
7416 return true;
7417 }
7418 if self.state.show_plant_menu {
7419 self.close_plant_menu();
7420 return true;
7421 }
7422 if self.state.show_farm_access {
7423 self.close_farm_access_panel();
7424 return true;
7425 }
7426 if self.state.sell_plot_confirm.is_some() {
7427 self.state.sell_plot_confirm = None;
7428 self.state.sell_plot_armed_at = None;
7429 self.state.push_log("Sell cancelled");
7430 return true;
7431 }
7432 if self.state.show_move_picker {
7433 self.close_move_picker();
7434 return true;
7435 }
7436 if self.state.show_rotation_editor {
7437 match self.state.rotation_editor.mode {
7438 RotationEditorMode::List => {
7439 self.state.show_rotation_editor = false;
7440 self.state.rotation_editor.reset();
7441 }
7442 RotationEditorMode::EditLabel => {
7443 self.state.rotation_editor.label_buffer.clear();
7444 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
7445 }
7446 RotationEditorMode::PickAbility => {
7447 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
7448 }
7449 RotationEditorMode::EditSequence => {
7450 self.state.rotation_editor.draft = None;
7451 self.state.rotation_editor.mode = RotationEditorMode::List;
7452 }
7453 }
7454 return true;
7455 }
7456 if self.state.show_inventory_menu {
7457 self.close_inventory_menu();
7458 return true;
7459 }
7460 if self.state.show_craft_menu {
7461 self.close_craft_menu();
7462 return true;
7463 }
7464 if self.state.show_plot_build_menu {
7465 self.close_plot_build_menu();
7466 return true;
7467 }
7468 if self.state.show_keychain_menu {
7469 self.close_keychain_menu();
7470 return true;
7471 }
7472 if self.state.show_quest_offer {
7473 self.quest_offer_decline();
7474 return true;
7475 }
7476 if self.state.show_shop_menu {
7477 return false;
7479 }
7480 if self.state.bank_panel.is_some() {
7481 return false;
7482 }
7483 if self.state.storage_panel.is_some() {
7484 return false;
7485 }
7486 if self.state.market_panel.is_some() {
7487 return false;
7488 }
7489 if self.state.show_npc_chat {
7490 return false;
7492 }
7493 if self.state.show_npc_verb_menu {
7494 self.state.show_npc_verb_menu = false;
7495 self.state.npc_verb_target = None;
7496 return true;
7497 }
7498 if self.state.show_quest_menu {
7499 if self.state.quest_withdraw_confirm {
7500 self.state.quest_withdraw_confirm = false;
7501 } else {
7502 self.state.show_quest_menu = false;
7503 }
7504 return true;
7505 }
7506 if self.state.worker_route_editor.is_some() {
7507 if self.re_at_root_sheet() {
7509 let reopen = self.state.attending_worker_instance_id.clone();
7510 self.close_worker_route_editor();
7511 if let Some(id) = reopen {
7512 if let Some(idx) = self
7513 .state
7514 .hired_workers
7515 .iter()
7516 .position(|w| w.instance_id == id)
7517 {
7518 self.state.workers_menu_index = idx;
7519 self.state.show_workers_menu = true;
7520 }
7521 }
7522 } else {
7523 self.re_sheet_back();
7524 }
7525 return true;
7526 }
7527 if self.state.show_worker_give_picker {
7528 self.close_worker_give_picker();
7529 return true;
7530 }
7531 if self.state.show_worker_give_target_picker {
7532 self.close_worker_give_target_picker();
7533 return true;
7534 }
7535 if self.state.show_worker_take_picker {
7536 self.close_worker_take_picker();
7537 return true;
7538 }
7539 if self.state.show_worker_teach_picker {
7540 self.close_worker_teach_picker();
7541 return true;
7542 }
7543 if self.state.show_workers_menu {
7544 self.close_workers_menu_ui();
7545 return true;
7546 }
7547 if self.state.show_loadout_menu {
7548 self.state.show_loadout_menu = false;
7549 return true;
7550 }
7551 if self.state.show_stats {
7552 self.state.show_stats = false;
7553 return true;
7554 }
7555 if self.state.show_equip_menu {
7556 self.state.show_equip_menu = false;
7557 return true;
7558 }
7559 false
7560 }
7561
7562 pub fn toggle_stats(&mut self) {
7563 self.state.show_stats = !self.state.show_stats;
7564 if self.state.show_stats {
7565 self.state.character_sheet_tab = CharacterSheetTab::Character;
7566 self.state.show_craft_menu = false;
7567 self.state.show_shop_menu = false;
7568 self.state.shop_catalog = None;
7569 self.state.show_inventory_menu = false;
7570 self.state.show_equip_menu = false;
7571 }
7572 }
7573
7574 pub fn toggle_equip_menu(&mut self) {
7575 self.state.show_equip_menu = !self.state.show_equip_menu;
7576 if self.state.show_equip_menu {
7577 self.state.show_stats = false;
7578 self.state.show_craft_menu = false;
7579 self.state.show_shop_menu = false;
7580 self.state.shop_catalog = None;
7581 self.state.show_inventory_menu = false;
7582 self.state.show_loadout_menu = false;
7583 }
7584 }
7585
7586 pub fn cycle_character_sheet_tab(&mut self) {
7587 if self.state.show_stats {
7588 self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
7589 }
7590 }
7591
7592 pub fn set_ledger_period_digit(&mut self, c: char) {
7593 if self.state.show_stats {
7594 if let Some(p) = LedgerPeriod::from_digit(c) {
7595 self.state.ledger_period = p;
7596 self.state.character_sheet_tab = CharacterSheetTab::Ledger;
7597 }
7598 }
7599 }
7600
7601 pub fn cycle_ledger_period(&mut self) {
7602 if self.state.show_stats && self.state.character_sheet_tab == CharacterSheetTab::Ledger {
7603 self.state.ledger_period = self.state.ledger_period.cycle();
7604 }
7605 }
7606
7607 pub fn open_inventory_menu(&mut self) {
7608 self.state.show_inventory_menu = true;
7609 self.state.show_craft_menu = false;
7610 self.state.show_shop_menu = false;
7611 self.state.shop_catalog = None;
7612 self.state.show_stats = false;
7613 self.state.show_move_picker = false;
7614 self.state.move_picker = None;
7615 self.state.show_destroy_picker = false;
7616 self.state.destroy_confirm_pending = false;
7617 self.state.destroy_picker = None;
7618 self.state.show_rename_prompt = false;
7619 self.state.rename_plot_id = None;
7620 self.state.rename_buffer.clear();
7621 self.state.inventory_filter_focused = false;
7622 self.state.clamp_inventory_indices();
7623 }
7624
7625 pub fn close_inventory_menu(&mut self) {
7626 self.state.show_inventory_menu = false;
7627 self.state.show_move_picker = false;
7628 self.state.move_picker = None;
7629 self.close_grant_picker();
7630 self.state.show_destroy_picker = false;
7631 self.state.destroy_confirm_pending = false;
7632 self.state.destroy_picker = None;
7633 self.state.show_rename_prompt = false;
7634 self.state.rename_plot_id = None;
7635 self.state.rename_buffer.clear();
7636 self.state.inventory_filter_focused = false;
7637 }
7638
7639 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
7640 let Some(row) = self.state.inventory_selected_row() else {
7641 anyhow::bail!("inventory empty");
7642 };
7643 if GameState::is_property_deed_template(&row.stack.template_id) {
7644 let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
7645 anyhow::bail!("deed has no plot id");
7646 };
7647 let label = self
7648 .state
7649 .property_plots
7650 .iter()
7651 .find(|p| p.plot_id == plot_id)
7652 .map(|p| {
7653 if p.label.trim().is_empty() {
7654 p.plot_code.clone()
7655 } else {
7656 p.label.clone()
7657 }
7658 })
7659 .unwrap_or_else(|| {
7660 row.stack
7661 .display_name
7662 .clone()
7663 .unwrap_or_else(|| "plot".into())
7664 });
7665 self.state.rename_buffer = label;
7666 self.state.rename_plot_id = Some(plot_id);
7667 self.state.highlighted_plot_id = Some(plot_id);
7668 self.state.show_rename_prompt = true;
7669 self.state.show_worker_rename = false;
7670 self.state.show_move_picker = false;
7671 self.state.show_destroy_picker = false;
7672 self.state.destroy_confirm_pending = false;
7673 return Ok(());
7674 }
7675 if !self.state.row_is_renameable_container(&row) {
7676 anyhow::bail!("only storage containers or deeds can be renamed");
7677 }
7678 let current = row
7679 .stack
7680 .display_name
7681 .clone()
7682 .unwrap_or_else(|| row.stack.template_id.clone());
7683 self.state.rename_buffer = current;
7684 self.state.rename_plot_id = None;
7685 self.state.show_rename_prompt = true;
7686 self.state.show_worker_rename = false;
7687 self.state.show_move_picker = false;
7688 self.state.show_destroy_picker = false;
7689 self.state.destroy_confirm_pending = false;
7690 Ok(())
7691 }
7692
7693 pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
7695 let Some(plot) = self.state.my_plot_under_player().cloned() else {
7696 anyhow::bail!("stand on your plot to rename it");
7697 };
7698 let label = if plot.label.trim().is_empty() {
7699 plot.plot_code.clone()
7700 } else {
7701 plot.label.clone()
7702 };
7703 self.state.rename_buffer = label;
7704 self.state.rename_plot_id = Some(plot.plot_id);
7705 self.state.highlighted_plot_id = Some(plot.plot_id);
7706 self.state.show_rename_prompt = true;
7707 self.state.show_worker_rename = false;
7708 Ok(())
7709 }
7710
7711 pub fn cancel_rename_prompt(&mut self) {
7712 self.state.show_rename_prompt = false;
7713 self.state.rename_plot_id = None;
7714 self.state.rename_buffer.clear();
7715 }
7716
7717 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
7718 let name = self.state.rename_buffer.trim().to_string();
7719 if name.is_empty() {
7720 anyhow::bail!("name cannot be empty");
7721 }
7722 if let Some(plot_id) = self.state.rename_plot_id {
7723 if name.chars().count() > 48 {
7724 anyhow::bail!("label must be 1–48 characters");
7725 }
7726 self.seq += 1;
7727 self.session
7728 .submit_intent(Intent::RenamePropertyPlot {
7729 entity_id: self.state.entity_id,
7730 plot_id,
7731 label: name,
7732 seq: self.seq,
7733 })
7734 .await?;
7735 self.state.intents_sent += 1;
7736 self.state.show_rename_prompt = false;
7737 self.state.rename_plot_id = None;
7738 self.state.rename_buffer.clear();
7739 return Ok(());
7740 }
7741 if name.chars().count() > 32 {
7742 anyhow::bail!("name must be 1–32 characters");
7743 }
7744 let Some(row) = self.state.inventory_selected_row() else {
7745 anyhow::bail!("inventory empty");
7746 };
7747 let Some(instance_id) = row.stack.item_instance_id else {
7748 anyhow::bail!("item has no instance id");
7749 };
7750 self.seq += 1;
7751 self.session
7752 .submit_intent(Intent::RenameContainer {
7753 entity_id: self.state.entity_id,
7754 item_instance_id: instance_id,
7755 location: row.from.clone(),
7756 name,
7757 seq: self.seq,
7758 })
7759 .await?;
7760 self.state.intents_sent += 1;
7761 self.state.show_rename_prompt = false;
7762 self.state.rename_buffer.clear();
7763 Ok(())
7764 }
7765
7766 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
7767 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7768 anyhow::bail!("no worker selected");
7769 };
7770 self.state.rename_buffer = worker.label.clone();
7771 self.state.show_worker_rename = true;
7772 self.state.show_rename_prompt = false;
7773 Ok(())
7774 }
7775
7776 pub fn cancel_worker_rename(&mut self) {
7777 self.state.show_worker_rename = false;
7778 self.state.rename_buffer.clear();
7779 }
7780
7781 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
7782 let name = self.state.rename_buffer.trim().to_string();
7783 if name.is_empty() {
7784 anyhow::bail!("name cannot be empty");
7785 }
7786 if name.chars().count() > 32 {
7787 anyhow::bail!("name must be 1–32 characters");
7788 }
7789 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7790 anyhow::bail!("no worker selected");
7791 };
7792 let worker_instance_id = worker.instance_id.clone();
7793 self.seq += 1;
7794 self.session
7795 .submit_intent(Intent::RenameHiredWorker {
7796 entity_id: self.state.entity_id,
7797 worker_instance_id: worker_instance_id.clone(),
7798 name: name.clone(),
7799 seq: self.seq,
7800 })
7801 .await?;
7802 self.state.intents_sent += 1;
7803 if let Some(w) = self
7804 .state
7805 .hired_workers
7806 .iter_mut()
7807 .find(|w| w.instance_id == worker_instance_id)
7808 {
7809 w.label = name.clone();
7810 }
7811 if let Some(ed) = self.state.worker_route_editor.as_mut() {
7812 if ed.worker_instance_id == worker_instance_id {
7813 ed.worker_label = name.clone();
7814 }
7815 }
7816 self.state.show_worker_rename = false;
7817 self.state.rename_buffer.clear();
7818 self.state.push_log(format!("Renamed worker to \"{name}\""));
7819 Ok(())
7820 }
7821
7822 pub fn toggle_inventory_menu(&mut self) {
7823 if self.state.show_inventory_menu {
7824 self.close_inventory_menu();
7825 } else {
7826 self.open_inventory_menu();
7827 }
7828 }
7829
7830 pub fn inventory_menu_move(&mut self, delta: i32) {
7832 if self.state.show_grant_picker {
7833 let Some(picker) = self.state.grant_picker.as_ref() else {
7834 return;
7835 };
7836 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7837 let filter = picker.filter.clone();
7838 let n = labels.len();
7839 if n == 0 {
7840 return;
7841 }
7842 self.state.grant_picker_index =
7843 step_filtered_index(self.state.grant_picker_index, delta, n, |i| {
7844 list_label_matches(&labels[i], &filter)
7845 });
7846 return;
7847 }
7848 if self.state.show_move_picker {
7849 let Some(picker) = self.state.move_picker.as_ref() else {
7850 return;
7851 };
7852 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7853 let filter = picker.filter.clone();
7854 let n = labels.len();
7855 if n == 0 {
7856 return;
7857 }
7858 self.state.move_picker_index =
7859 step_filtered_index(self.state.move_picker_index, delta, n, |i| {
7860 list_label_matches(&labels[i], &filter)
7861 });
7862 self.state.clamp_move_picker_quantity();
7863 return;
7864 }
7865 let n = self.state.inventory_selectable_rows().len();
7866 if n == 0 {
7867 return;
7868 }
7869 let idx = self.state.inventory_menu_index as i32;
7870 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
7871 }
7872
7873 pub fn inventory_menu_page(&mut self, pages: i32) {
7875 if self.state.show_grant_picker {
7876 let Some(picker) = self.state.grant_picker.as_ref() else {
7877 return;
7878 };
7879 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7880 let filter = picker.filter.clone();
7881 let n = labels.len();
7882 self.state.grant_picker_index =
7883 page_filtered_index(self.state.grant_picker_index, pages, n, |i| {
7884 list_label_matches(&labels[i], &filter)
7885 });
7886 return;
7887 }
7888 if self.state.show_move_picker {
7889 let Some(picker) = self.state.move_picker.as_ref() else {
7890 return;
7891 };
7892 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7893 let filter = picker.filter.clone();
7894 let n = labels.len();
7895 self.state.move_picker_index =
7896 page_filtered_index(self.state.move_picker_index, pages, n, |i| {
7897 list_label_matches(&labels[i], &filter)
7898 });
7899 self.state.clamp_move_picker_quantity();
7900 return;
7901 }
7902 let n = self.state.inventory_selectable_rows().len();
7903 self.state.inventory_menu_index =
7904 page_list_index(self.state.inventory_menu_index, pages, n);
7905 }
7906
7907 pub fn cycle_inventory_tab(&mut self, forward: bool) {
7908 if self.state.show_move_picker
7909 || self.state.show_grant_picker
7910 || self.state.show_destroy_picker
7911 || self.state.show_rename_prompt
7912 || self.state.inventory_filter_focused
7913 {
7914 return;
7915 }
7916 self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
7917 self.state.inventory_menu_index = 0;
7918 self.state.clamp_inventory_indices();
7919 }
7920
7921 pub fn focus_inventory_filter(&mut self) {
7922 if self.state.show_grant_picker {
7923 if let Some(p) = self.state.grant_picker.as_mut() {
7924 p.filter_focused = true;
7925 }
7926 return;
7927 }
7928 if self.state.show_move_picker {
7929 if let Some(p) = self.state.move_picker.as_mut() {
7930 p.filter_focused = true;
7931 }
7932 return;
7933 }
7934 self.state.inventory_filter_focused = true;
7935 }
7936
7937 pub fn set_inventory_filter(&mut self, filter: String) {
7938 self.state.inventory_filter = filter;
7939 self.state.inventory_menu_index = 0;
7940 self.state.clamp_inventory_indices();
7941 }
7942
7943 pub fn append_inventory_filter_char(&mut self, ch: char) {
7944 if ch.is_control() {
7945 return;
7946 }
7947 if self.state.show_grant_picker {
7948 if let Some(p) = self.state.grant_picker.as_mut() {
7949 if p.filter_focused {
7950 p.filter.push(ch);
7951 self.state.grant_picker_index = 0;
7952 }
7953 }
7954 return;
7955 }
7956 if self.state.show_move_picker {
7957 if let Some(p) = self.state.move_picker.as_mut() {
7958 if p.filter_focused {
7959 p.filter.push(ch);
7960 self.state.move_picker_index = 0;
7961 self.state.clamp_move_picker_quantity();
7962 }
7963 }
7964 return;
7965 }
7966 if !self.state.inventory_filter_focused {
7967 return;
7968 }
7969 self.state.inventory_filter.push(ch);
7970 self.state.inventory_menu_index = 0;
7971 self.state.clamp_inventory_indices();
7972 }
7973
7974 pub fn inventory_filter_backspace(&mut self) {
7975 if self.state.show_grant_picker {
7976 if let Some(p) = self.state.grant_picker.as_mut() {
7977 if p.filter_focused {
7978 p.filter.pop();
7979 self.state.grant_picker_index = 0;
7980 }
7981 }
7982 return;
7983 }
7984 if self.state.show_move_picker {
7985 if let Some(p) = self.state.move_picker.as_mut() {
7986 if p.filter_focused {
7987 p.filter.pop();
7988 self.state.move_picker_index = 0;
7989 self.state.clamp_move_picker_quantity();
7990 }
7991 }
7992 return;
7993 }
7994 if !self.state.inventory_filter_focused {
7995 return;
7996 }
7997 self.state.inventory_filter.pop();
7998 self.state.inventory_menu_index = 0;
7999 self.state.clamp_inventory_indices();
8000 }
8001
8002 pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
8004 if self.state.show_grant_picker {
8005 if let Some(p) = self.state.grant_picker.as_mut() {
8006 if p.filter_focused {
8007 if !p.filter.is_empty() {
8008 p.filter.clear();
8009 self.state.grant_picker_index = 0;
8010 } else {
8011 p.filter_focused = false;
8012 }
8013 return true;
8014 }
8015 if !p.filter.is_empty() {
8016 p.filter.clear();
8017 self.state.grant_picker_index = 0;
8018 return true;
8019 }
8020 }
8021 return false;
8022 }
8023 if self.state.show_move_picker {
8024 if let Some(p) = self.state.move_picker.as_mut() {
8025 if p.filter_focused {
8026 if !p.filter.is_empty() {
8027 p.filter.clear();
8028 self.state.move_picker_index = 0;
8029 self.state.clamp_move_picker_quantity();
8030 } else {
8031 p.filter_focused = false;
8032 }
8033 return true;
8034 }
8035 if !p.filter.is_empty() {
8036 p.filter.clear();
8037 self.state.move_picker_index = 0;
8038 self.state.clamp_move_picker_quantity();
8039 return true;
8040 }
8041 }
8042 return false;
8043 }
8044 if self.state.inventory_filter_focused {
8045 if !self.state.inventory_filter.is_empty() {
8046 self.state.inventory_filter.clear();
8047 self.state.inventory_menu_index = 0;
8048 self.state.clamp_inventory_indices();
8049 } else {
8050 self.state.inventory_filter_focused = false;
8051 }
8052 return true;
8053 }
8054 if !self.state.inventory_filter.is_empty() {
8055 self.state.inventory_filter.clear();
8056 self.state.inventory_menu_index = 0;
8057 self.state.clamp_inventory_indices();
8058 return true;
8059 }
8060 false
8061 }
8062
8063 pub fn craft_menu_page(&mut self, pages: i32) {
8064 let n = self.state.blueprints.len();
8065 self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
8066 self.state.clamp_craft_batch_quantity();
8067 }
8068
8069 pub fn shop_menu_page(&mut self, pages: i32) {
8070 let n = self.state.shop_list_len();
8071 self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
8072 self.state.clamp_shop_quantity();
8073 }
8074
8075 pub fn workers_menu_page(&mut self, pages: i32) {
8076 let n = self.state.hired_workers.len();
8077 self.state.workers_menu_index = page_list_index(self.state.workers_menu_index, pages, n);
8078 }
8079
8080 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
8085 if self.state.show_destroy_picker {
8086 if self.state.destroy_confirm_pending {
8087 return self.confirm_destroy_item().await;
8088 }
8089 return self.request_destroy_confirm();
8090 }
8091 if self.state.show_grant_picker {
8092 return self.confirm_grant_picker().await;
8093 }
8094 if self.state.show_move_picker {
8095 return self.confirm_move_picker().await;
8096 }
8097 let Some(row) = self.state.inventory_selected_row() else {
8098 anyhow::bail!("inventory empty");
8099 };
8100 if row.is_equip_shell {
8101 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
8102 anyhow::bail!("not a worn item");
8103 };
8104 return self.equip_worn(slot, None).await;
8105 }
8106 if row.is_chest_shell {
8107 return self.open_chest_pickup_picker();
8108 }
8109 let template_id = row.stack.template_id.clone();
8110 let instance_id = row.stack.item_instance_id;
8111 let category = self.state.inventory_item_category(&template_id);
8112 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
8113
8114 if category == Some("weapon") {
8115 return self.equip_mainhand(Some(template_id)).await;
8116 }
8117 if category == Some("lodging") && on_person {
8118 if let Some(inst) = instance_id {
8119 return self.place_container(inst).await;
8120 }
8121 }
8122 if (category == Some("container") || category == Some("armor")) && on_person {
8123 if let Some(inst) = instance_id {
8124 let world_placeable =
8125 row.stack.world_placeable == Some(true) || template_id.contains("chest");
8126 if world_placeable {
8127 return self.place_container(inst).await;
8128 }
8129 if let Some(slot) = guess_body_slot(&template_id) {
8133 return self.equip_worn(slot, Some(inst)).await;
8134 }
8135 }
8136 }
8137 self.open_move_picker()
8141 }
8142
8143 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
8145 let Some(row) = self.state.inventory_selected_row() else {
8146 anyhow::bail!("inventory empty");
8147 };
8148 if row.from != flatland_protocol::InventoryLocation::Root {
8149 anyhow::bail!("select a consumable on your person");
8150 }
8151 if GameState::stack_is_item_grant(&row.stack) {
8152 return self.open_grant_target_picker();
8153 }
8154 if GameState::is_property_deed_template(&row.stack.template_id) {
8155 return self.open_move_picker();
8156 }
8157 let category = self.state.inventory_item_category(&row.stack.template_id);
8158 if category != Some("consumable") {
8159 anyhow::bail!("selected item is not consumable");
8160 }
8161 self.use_item(&row.stack.template_id).await
8162 }
8163
8164 pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
8166 let Some(row) = self.state.inventory_selected_row() else {
8167 anyhow::bail!("inventory empty");
8168 };
8169 if row.from != flatland_protocol::InventoryLocation::Root {
8170 anyhow::bail!("select a grant item on your person");
8171 }
8172 if !GameState::stack_is_item_grant(&row.stack) {
8173 anyhow::bail!("selected item does not grant onto gear");
8174 }
8175 let Some(grant_instance_id) = row.stack.item_instance_id else {
8176 anyhow::bail!("grant has no instance id");
8177 };
8178 let effect_id = GameState::grant_effect_id(&row.stack)
8179 .unwrap_or("?")
8180 .to_string();
8181 let mode = GameState::grant_mode(&row.stack).to_string();
8182 let options = self.state.grant_target_options(&row.stack);
8183 if options.is_empty() {
8184 anyhow::bail!("no valid gear to apply {effect_id} to");
8185 }
8186 let grant_label = row
8187 .stack
8188 .display_name
8189 .clone()
8190 .unwrap_or_else(|| row.stack.template_id.clone());
8191 self.state.show_grant_picker = true;
8192 self.state.grant_picker_index = 0;
8193 self.state.grant_picker = Some(GrantTargetPicker {
8194 grant_instance_id,
8195 grant_label,
8196 effect_id,
8197 mode,
8198 options,
8199 filter: String::new(),
8200 filter_focused: false,
8201 });
8202 Ok(())
8203 }
8204
8205 pub fn close_grant_picker(&mut self) {
8206 self.state.show_grant_picker = false;
8207 self.state.grant_picker = None;
8208 self.state.grant_picker_index = 0;
8209 }
8210
8211 pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
8212 let Some(picker) = self.state.grant_picker.clone() else {
8213 self.close_grant_picker();
8214 return Ok(());
8215 };
8216 let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
8217 self.close_grant_picker();
8218 return Ok(());
8219 };
8220 self.close_grant_picker();
8221 self.use_grant(picker.grant_instance_id, opt.target_instance_id)
8222 .await?;
8223 self.state
8224 .push_log(format!("Applying {} onto {}…", picker.effect_id, opt.label));
8225 Ok(())
8226 }
8227
8228 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
8232 let Some(row) = self.state.inventory_selected_row() else {
8233 anyhow::bail!("inventory empty");
8234 };
8235 if row.is_equip_shell {
8236 anyhow::bail!("this is a worn bag — press Enter to unequip it");
8237 }
8238 if row.is_chest_shell {
8239 return self.open_chest_pickup_picker();
8240 }
8241 let Some(instance_id) = row.stack.item_instance_id else {
8242 anyhow::bail!("item has no instance id");
8243 };
8244 let mut options = self.state.move_destinations_for(
8245 &row.from,
8246 row.from_parent_instance_id,
8247 row.stack.item_instance_id,
8248 &row.stack.template_id,
8249 );
8250 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
8251 let category = self.state.inventory_item_category(&row.stack.template_id);
8252 if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
8253 if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
8254 options.insert(
8255 0,
8256 MoveOption {
8257 label: "Sell plot to crown…".into(),
8258 kind: MoveOptionKind::SellPlotToCrown { plot_id },
8259 },
8260 );
8261 }
8262 }
8263 if on_person && category == Some("consumable") {
8264 if GameState::stack_is_item_grant(&row.stack) {
8265 options.insert(
8266 0,
8267 MoveOption {
8268 label: "Apply onto gear…".into(),
8269 kind: MoveOptionKind::GrantApply,
8270 },
8271 );
8272 } else {
8273 options.insert(
8274 0,
8275 MoveOption {
8276 label: "Use (eat / drink)".into(),
8277 kind: MoveOptionKind::Use,
8278 },
8279 );
8280 }
8281 }
8282 let item_label = row
8283 .stack
8284 .display_name
8285 .clone()
8286 .unwrap_or_else(|| row.stack.template_id.clone());
8287 let initial_qty = if row.stack.quantity > 1 {
8290 1
8291 } else {
8292 row.stack.quantity
8293 };
8294 self.state.move_picker = Some(MovePicker {
8295 item_instance_id: instance_id,
8296 from: row.from,
8297 item_label,
8298 template_id: row.stack.template_id.clone(),
8299 stack_quantity: row.stack.quantity,
8300 quantity: initial_qty.max(1),
8301 options,
8302 filter: String::new(),
8303 filter_focused: false,
8304 });
8305 self.state.move_picker_index = 0;
8306 self.state.show_move_picker = true;
8307 self.state.show_destroy_picker = false;
8308 self.state.destroy_confirm_pending = false;
8309 self.state.destroy_picker = None;
8310 self.state.clamp_move_picker_quantity();
8311 Ok(())
8312 }
8313
8314 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
8316 let Some(row) = self.state.inventory_selected_row() else {
8317 anyhow::bail!("inventory empty");
8318 };
8319 if !row.is_chest_shell {
8320 anyhow::bail!("not a placed chest");
8321 }
8322 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
8323 anyhow::bail!("not a placed chest");
8324 };
8325 let Some(instance_id) = row.stack.item_instance_id else {
8326 anyhow::bail!("chest has no instance id");
8327 };
8328 let chest = self
8329 .state
8330 .placed_containers
8331 .iter()
8332 .find(|c| c.id == *container_id)
8333 .cloned()
8334 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
8335 let (px, py) = self.state.player_position();
8336 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
8337 anyhow::bail!("too far from {}", chest.display_name);
8338 }
8339 if chest.locked && !chest.accessible {
8340 anyhow::bail!(
8341 "need the matching key for {} before picking it up",
8342 chest.display_name
8343 );
8344 }
8345 let options = self.state.chest_pickup_destinations(container_id);
8346 let item_label = row
8347 .stack
8348 .display_name
8349 .clone()
8350 .unwrap_or_else(|| row.stack.template_id.clone());
8351 self.state.move_picker = Some(MovePicker {
8352 item_instance_id: instance_id,
8353 from: row.from.clone(),
8354 item_label,
8355 template_id: row.stack.template_id.clone(),
8356 stack_quantity: 1,
8357 quantity: 1,
8358 options,
8359 filter: String::new(),
8360 filter_focused: false,
8361 });
8362 self.state.move_picker_index = 0;
8363 self.state.show_move_picker = true;
8364 self.state.show_destroy_picker = false;
8365 self.state.destroy_confirm_pending = false;
8366 self.state.destroy_picker = None;
8367 Ok(())
8368 }
8369
8370 pub fn close_move_picker(&mut self) {
8371 self.state.show_move_picker = false;
8372 self.state.move_picker = None;
8373 }
8374
8375 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
8376 self.state.move_picker_adjust_quantity(delta);
8377 }
8378
8379 pub fn move_picker_set_quantity_max(&mut self) {
8380 self.state.move_picker_set_quantity_max();
8381 }
8382
8383 pub fn move_picker_set_quantity_min(&mut self) {
8384 self.state.move_picker_set_quantity_min();
8385 }
8386
8387 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
8388 self.state.destroy_picker_adjust_quantity(delta);
8389 }
8390
8391 pub fn destroy_picker_set_quantity_max(&mut self) {
8392 self.state.destroy_picker_set_quantity_max();
8393 }
8394
8395 pub fn destroy_picker_set_quantity_min(&mut self) {
8396 self.state.destroy_picker_set_quantity_min();
8397 }
8398
8399 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
8400 let Some(picker) = self.state.move_picker.clone() else {
8401 self.close_move_picker();
8402 return Ok(());
8403 };
8404 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
8405 self.close_move_picker();
8406 return Ok(());
8407 };
8408 match option.kind {
8409 MoveOptionKind::Cancel => {
8410 self.close_move_picker();
8411 }
8412 MoveOptionKind::Use => {
8413 self.close_move_picker();
8414 self.use_item(&picker.template_id).await?;
8415 }
8416 MoveOptionKind::GrantApply => {
8417 self.close_move_picker();
8418 self.open_grant_target_picker()?;
8419 }
8420 MoveOptionKind::SellPlotToCrown { plot_id } => {
8421 self.close_move_picker();
8422 self.confirm_sell_plot_to_crown(plot_id).await?;
8423 }
8424 MoveOptionKind::RelocatePlaced { container_id } => {
8425 self.close_move_picker();
8426 self.state.show_inventory_menu = false;
8427 self.begin_relocate_container(&container_id)?;
8428 }
8429 MoveOptionKind::Drop => {
8430 self.close_move_picker();
8431 if self
8432 .state
8433 .hand_equipped_instance_ids()
8434 .contains(&picker.item_instance_id)
8435 {
8436 anyhow::bail!("unequip that item first");
8437 }
8438 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
8439 if self.state.deed_bound(&stack) {
8440 anyhow::bail!(
8441 "cannot drop a property deed — store it or trade it to another player"
8442 );
8443 }
8444 if self.state.key_drop_blocked(&stack) {
8445 anyhow::bail!("cannot drop the key while its chest is locked");
8446 }
8447 }
8448 self.drop_item(picker.item_instance_id, picker.from).await?;
8449 self.state
8450 .push_log(format!("Dropped {}", picker.item_label));
8451 }
8452 MoveOptionKind::PickupPlaced {
8453 container_id,
8454 nest_location,
8455 nest_parent_instance_id,
8456 } => {
8457 self.close_move_picker();
8458 self.pickup_container(container_id.clone()).await?;
8459 let nest_into_bag = nest_parent_instance_id.is_some()
8460 || !matches!(nest_location, flatland_protocol::InventoryLocation::Root);
8461 if nest_into_bag {
8462 self.move_item(
8463 picker.item_instance_id,
8464 flatland_protocol::InventoryLocation::Root,
8465 nest_location,
8466 nest_parent_instance_id,
8467 None,
8468 )
8469 .await?;
8470 self.state
8471 .push_log(format!("Picked up {} into bag", picker.item_label));
8472 } else {
8473 self.state
8474 .push_log(format!("Picked up {}", picker.item_label));
8475 }
8476 }
8477 MoveOptionKind::Move {
8478 location,
8479 parent_instance_id,
8480 } => {
8481 self.close_move_picker();
8482 let qty = if picker.quantity >= picker.stack_quantity {
8483 None
8484 } else {
8485 Some(picker.quantity)
8486 };
8487 self.move_item(
8488 picker.item_instance_id,
8489 picker.from,
8490 location,
8491 parent_instance_id,
8492 qty,
8493 )
8494 .await?;
8495 let moved = qty.unwrap_or(picker.stack_quantity);
8496 if moved >= picker.stack_quantity {
8497 self.state.push_log(format!("Moved {}", picker.item_label));
8498 } else {
8499 self.state.push_log(format!(
8500 "Moved {} ×{} of {}",
8501 picker.item_label, moved, picker.stack_quantity
8502 ));
8503 }
8504 }
8505 }
8506 Ok(())
8507 }
8508
8509 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
8511 let Some(row) = self.state.inventory_selected_row() else {
8512 anyhow::bail!("inventory empty");
8513 };
8514 if row.is_equip_shell {
8515 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
8516 }
8517 if row.is_chest_shell {
8518 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
8519 }
8520 let Some(inst) = row.stack.item_instance_id else {
8521 anyhow::bail!("item has no instance id");
8522 };
8523 if self.state.hand_equipped_instance_ids().contains(&inst) {
8524 anyhow::bail!("unequip that item first");
8525 }
8526 if self.state.deed_bound(&row.stack) {
8527 anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
8528 }
8529 if self.state.key_drop_blocked(&row.stack) {
8530 anyhow::bail!("cannot drop the key while its chest is locked");
8531 }
8532 let label = row
8533 .stack
8534 .display_name
8535 .clone()
8536 .unwrap_or_else(|| row.stack.template_id.clone());
8537 self.drop_item(inst, row.from).await?;
8538 self.state.push_log(format!("Dropped {label}"));
8539 Ok(())
8540 }
8541
8542 pub async fn drop_item(
8543 &mut self,
8544 item_instance_id: uuid::Uuid,
8545 from: flatland_protocol::InventoryLocation,
8546 ) -> anyhow::Result<()> {
8547 self.seq += 1;
8548 self.session
8549 .submit_intent(Intent::DropItem {
8550 entity_id: self.state.entity_id,
8551 item_instance_id,
8552 from,
8553 seq: self.seq,
8554 })
8555 .await?;
8556 self.state.intents_sent += 1;
8557 Ok(())
8558 }
8559
8560 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
8562 let Some(row) = self.state.inventory_selected_row() else {
8563 anyhow::bail!("inventory empty");
8564 };
8565 if row.is_equip_shell {
8566 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
8567 }
8568 if row.is_chest_shell {
8569 anyhow::bail!("can't destroy a placed chest from the inventory list");
8570 }
8571 let Some(instance_id) = row.stack.item_instance_id else {
8572 anyhow::bail!("item has no instance id");
8573 };
8574 if self
8575 .state
8576 .hand_equipped_instance_ids()
8577 .contains(&instance_id)
8578 {
8579 anyhow::bail!("unequip that item first");
8580 }
8581 if self.state.deed_bound(&row.stack) {
8582 anyhow::bail!(
8583 "cannot destroy a property deed — store it or trade it to another player"
8584 );
8585 }
8586 if self.state.key_drop_blocked(&row.stack) {
8587 anyhow::bail!("cannot destroy the key while its chest is locked");
8588 }
8589 let item_label = row
8590 .stack
8591 .display_name
8592 .clone()
8593 .unwrap_or_else(|| row.stack.template_id.clone());
8594 self.state.destroy_picker = Some(DestroyPicker {
8595 item_instance_id: instance_id,
8596 from: row.from,
8597 item_label,
8598 stack_quantity: row.stack.quantity,
8599 quantity: row.stack.quantity,
8600 });
8601 self.state.destroy_confirm_pending = false;
8602 self.state.show_destroy_picker = true;
8603 self.state.show_move_picker = false;
8604 self.state.move_picker = None;
8605 Ok(())
8606 }
8607
8608 pub fn close_destroy_picker(&mut self) {
8609 self.state.show_destroy_picker = false;
8610 self.state.destroy_confirm_pending = false;
8611 self.state.destroy_picker = None;
8612 }
8613
8614 pub fn cancel_destroy_confirm(&mut self) {
8615 self.state.destroy_confirm_pending = false;
8616 }
8617
8618 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
8619 if self.state.destroy_picker.is_none() {
8620 self.close_destroy_picker();
8621 return Ok(());
8622 }
8623 self.state.destroy_confirm_pending = true;
8624 Ok(())
8625 }
8626
8627 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
8628 let Some(picker) = self.state.destroy_picker.clone() else {
8629 self.close_destroy_picker();
8630 return Ok(());
8631 };
8632 let qty = if picker.quantity >= picker.stack_quantity {
8633 None
8634 } else {
8635 Some(picker.quantity)
8636 };
8637 self.destroy_item(picker.item_instance_id, picker.from, qty)
8638 .await?;
8639 let destroyed = qty.unwrap_or(picker.stack_quantity);
8640 if destroyed >= picker.stack_quantity {
8641 self.state
8642 .push_log(format!("Destroyed {}", picker.item_label));
8643 } else {
8644 self.state.push_log(format!(
8645 "Destroyed {} ×{} of {}",
8646 picker.item_label, destroyed, picker.stack_quantity
8647 ));
8648 }
8649 self.close_destroy_picker();
8650 Ok(())
8651 }
8652
8653 pub async fn destroy_item(
8654 &mut self,
8655 item_instance_id: uuid::Uuid,
8656 from: flatland_protocol::InventoryLocation,
8657 quantity: Option<u32>,
8658 ) -> anyhow::Result<()> {
8659 self.seq += 1;
8660 self.session
8661 .submit_intent(Intent::DestroyItem {
8662 entity_id: self.state.entity_id,
8663 item_instance_id,
8664 from,
8665 quantity,
8666 seq: self.seq,
8667 })
8668 .await?;
8669 self.state.intents_sent += 1;
8670 Ok(())
8671 }
8672
8673 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
8675 if let Some(row) = self.state.inventory_selected_row() {
8676 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
8677 return self.toggle_placed_chest_lock(container_id).await;
8678 }
8679 }
8680 self.toggle_nearby_chest_lock().await
8681 }
8682
8683 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
8684 let chest = self
8685 .state
8686 .placed_containers
8687 .iter()
8688 .find(|c| c.id == container_id)
8689 .cloned()
8690 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
8691 let (px, py) = self.state.player_position();
8692 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
8693 anyhow::bail!("too far from {}", chest.display_name);
8694 }
8695 if !chest.accessible && chest.locked {
8696 anyhow::bail!(
8697 "need the matching key for {} (each crafted chest has its own key)",
8698 chest.display_name
8699 );
8700 }
8701 let lock = !chest.locked;
8702 self.set_container_locked(
8703 flatland_protocol::InventoryLocation::Placed {
8704 container_id: chest.id.clone(),
8705 },
8706 lock,
8707 )
8708 .await?;
8709 self.state.push_log(if lock {
8710 format!("Locked {}", chest.display_name)
8711 } else {
8712 format!("Unlocked {}", chest.display_name)
8713 });
8714 Ok(())
8715 }
8716
8717 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
8719 let chest = self
8720 .state
8721 .nearest_placed_container(CONTAINER_RANGE_M)
8722 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
8723 self.toggle_placed_chest_lock(&chest.id).await
8724 }
8725
8726 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
8727 self.equip_mainhand(None).await
8728 }
8729
8730 pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
8731 if !self.state.is_alive() {
8732 anyhow::bail!("you are dead");
8733 }
8734 self.seq += 1;
8735 self.session
8736 .submit_intent(Intent::EquipOffhand {
8737 entity_id: self.state.entity_id,
8738 template_id,
8739 instance_id: None,
8740 seq: self.seq,
8741 })
8742 .await?;
8743 self.state.intents_sent += 1;
8744 Ok(())
8745 }
8746
8747 pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
8748 self.equip_offhand(None).await
8749 }
8750
8751 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
8752 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
8753 for slot in slots {
8754 self.equip_worn(slot, None).await?;
8755 }
8756 Ok(())
8757 }
8758
8759 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
8760 let (px, py) = self.state.player_position();
8761 let nearest = self
8762 .state
8763 .placed_containers
8764 .iter()
8765 .min_by(|a, b| {
8766 let da = (a.x - px).hypot(a.y - py);
8767 let db = (b.x - px).hypot(b.y - py);
8768 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
8769 })
8770 .cloned();
8771 let Some(chest) = nearest else {
8772 anyhow::bail!("no chest nearby");
8773 };
8774 if (chest.x - px).hypot(chest.y - py) > 2.0 {
8775 anyhow::bail!("too far from chest");
8776 }
8777 self.pickup_container(chest.id).await
8778 }
8779
8780 pub async fn equip_worn(
8781 &mut self,
8782 slot: BodySlot,
8783 instance_id: Option<uuid::Uuid>,
8784 ) -> anyhow::Result<()> {
8785 self.seq += 1;
8786 self.session
8787 .submit_intent(Intent::EquipWorn {
8788 entity_id: self.state.entity_id,
8789 slot,
8790 instance_id,
8791 seq: self.seq,
8792 })
8793 .await?;
8794 self.state.intents_sent += 1;
8795 Ok(())
8796 }
8797
8798 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
8799 self.seq += 1;
8800 self.session
8801 .submit_intent(Intent::PlaceContainer {
8802 entity_id: self.state.entity_id,
8803 item_instance_id,
8804 seq: self.seq,
8805 })
8806 .await?;
8807 self.state.intents_sent += 1;
8808 Ok(())
8809 }
8810
8811 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
8812 self.seq += 1;
8813 self.session
8814 .submit_intent(Intent::PickupContainer {
8815 entity_id: self.state.entity_id,
8816 container_id,
8817 seq: self.seq,
8818 })
8819 .await?;
8820 self.state.intents_sent += 1;
8821 Ok(())
8822 }
8823
8824 pub async fn move_item(
8825 &mut self,
8826 item_instance_id: uuid::Uuid,
8827 from: flatland_protocol::InventoryLocation,
8828 to: flatland_protocol::InventoryLocation,
8829 to_parent_instance_id: Option<uuid::Uuid>,
8830 quantity: Option<u32>,
8831 ) -> anyhow::Result<()> {
8832 self.seq += 1;
8833 self.session
8834 .submit_intent(Intent::MoveItem {
8835 entity_id: self.state.entity_id,
8836 item_instance_id,
8837 from,
8838 to,
8839 to_parent_instance_id,
8840 quantity,
8841 seq: self.seq,
8842 })
8843 .await?;
8844 self.state.intents_sent += 1;
8845 Ok(())
8846 }
8847
8848 pub async fn set_container_locked(
8849 &mut self,
8850 location: flatland_protocol::InventoryLocation,
8851 locked: bool,
8852 ) -> anyhow::Result<()> {
8853 self.seq += 1;
8854 self.session
8855 .submit_intent(Intent::SetContainerLocked {
8856 entity_id: self.state.entity_id,
8857 location,
8858 locked,
8859 seq: self.seq,
8860 })
8861 .await?;
8862 self.state.intents_sent += 1;
8863 Ok(())
8864 }
8865
8866 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
8867 if !self.state.is_alive() {
8868 anyhow::bail!("you are dead");
8869 }
8870 self.seq += 1;
8871 self.session
8872 .submit_intent(Intent::Use {
8873 entity_id: self.state.entity_id,
8874 template_id: template_id.to_string(),
8875 seq: self.seq,
8876 })
8877 .await?;
8878 self.state.intents_sent += 1;
8879 Ok(())
8880 }
8881
8882 pub async fn use_grant(
8884 &mut self,
8885 grant_instance_id: uuid::Uuid,
8886 target_instance_id: uuid::Uuid,
8887 ) -> anyhow::Result<()> {
8888 if !self.state.is_alive() {
8889 anyhow::bail!("you are dead");
8890 }
8891 self.seq += 1;
8892 self.session
8893 .submit_intent(Intent::UseGrant {
8894 entity_id: self.state.entity_id,
8895 grant_instance_id,
8896 target_instance_id,
8897 seq: self.seq,
8898 })
8899 .await?;
8900 self.state.intents_sent += 1;
8901 Ok(())
8902 }
8903
8904 pub fn open_craft_menu(&mut self) {
8905 self.state.show_craft_menu = true;
8906 self.state.show_shop_menu = false;
8907 self.state.shop_catalog = None;
8908 self.state.show_stats = false;
8909 self.state.show_inventory_menu = false;
8910 if self.state.blueprints.is_empty() {
8911 self.state.craft_menu_index = 0;
8912 self.state.craft_batch_quantity = 1;
8913 return;
8914 }
8915 self.state.craft_menu_index = self
8916 .state
8917 .craft_menu_index
8918 .min(self.state.blueprints.len() - 1);
8919 if let Some(idx) = self
8920 .state
8921 .blueprints
8922 .iter()
8923 .position(|bp| self.state.can_craft_blueprint(bp))
8924 {
8925 self.state.craft_menu_index = idx;
8926 }
8927 self.state.clamp_craft_batch_quantity();
8928 }
8929
8930 pub fn close_craft_menu(&mut self) {
8931 self.state.show_craft_menu = false;
8932 }
8933
8934 pub fn toggle_keychain_menu(&mut self) {
8935 if self.state.show_keychain_menu {
8936 self.close_keychain_menu();
8937 } else {
8938 self.state.show_keychain_menu = true;
8939 self.state.show_craft_menu = false;
8940 self.state.show_shop_menu = false;
8941 self.state.show_inventory_menu = false;
8942 let n = self.state.keychain_entries().len();
8943 if n == 0 {
8944 self.state.keychain_menu_index = 0;
8945 } else {
8946 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
8947 }
8948 }
8949 }
8950
8951 pub fn close_keychain_menu(&mut self) {
8952 self.state.show_keychain_menu = false;
8953 }
8954
8955 pub fn keychain_menu_move(&mut self, delta: i32) {
8956 let n = self.state.keychain_entries().len();
8957 if n == 0 {
8958 self.state.keychain_menu_index = 0;
8959 return;
8960 }
8961 let idx = self.state.keychain_menu_index as i32 + delta;
8962 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
8963 }
8964
8965 pub fn keychain_menu_page(&mut self, pages: i32) {
8966 let n = self.state.keychain_entries().len();
8967 self.state.keychain_menu_index = page_list_index(self.state.keychain_menu_index, pages, n);
8968 }
8969
8970 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
8971 if !self.state.is_alive() {
8972 anyhow::bail!("you are dead");
8973 }
8974 let entries = self.state.keychain_entries();
8975 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
8976 anyhow::bail!("nothing selected");
8977 };
8978 let Some(instance_id) = entry.stack.item_instance_id else {
8979 anyhow::bail!("key has no instance id");
8980 };
8981 if entry.stowed {
8982 self.move_item(
8983 instance_id,
8984 flatland_protocol::InventoryLocation::Keychain,
8985 flatland_protocol::InventoryLocation::Root,
8986 None,
8987 Some(1),
8988 )
8989 .await
8990 } else {
8991 self.move_item(
8992 instance_id,
8993 flatland_protocol::InventoryLocation::Root,
8994 flatland_protocol::InventoryLocation::Keychain,
8995 None,
8996 Some(1),
8997 )
8998 .await
8999 }
9000 }
9001
9002 pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
9003 let npc_id = self.state.shop_catalog.as_ref().map(|c| c.npc_id.clone());
9004 self.state.show_shop_menu = false;
9005 self.state.shop_catalog = None;
9006 self.state.clear_shop_trade_log();
9007 if let Some(npc_id) = npc_id {
9008 self.seq += 1;
9009 self.session
9010 .submit_intent(Intent::ShopClose {
9011 entity_id: self.state.entity_id,
9012 npc_id,
9013 seq: self.seq,
9014 })
9015 .await?;
9016 self.state.intents_sent += 1;
9017 }
9018 Ok(())
9019 }
9020
9021 pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
9022 let Some(panel) = self.state.bank_panel.clone() else {
9023 return Ok(());
9024 };
9025 self.seq += 1;
9026 self.session
9027 .submit_intent(Intent::BankDeposit {
9028 entity_id: self.state.entity_id,
9029 npc_id: panel.npc_id,
9030 amount_copper,
9031 seq: self.seq,
9032 })
9033 .await?;
9034 self.state.intents_sent += 1;
9035 Ok(())
9036 }
9037
9038 pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
9039 let Some(panel) = self.state.bank_panel.clone() else {
9040 return Ok(());
9041 };
9042 self.seq += 1;
9043 self.session
9044 .submit_intent(Intent::BankWithdraw {
9045 entity_id: self.state.entity_id,
9046 npc_id: panel.npc_id,
9047 amount_copper,
9048 seq: self.seq,
9049 })
9050 .await?;
9051 self.state.intents_sent += 1;
9052 Ok(())
9053 }
9054
9055 pub async fn bank_transfer(
9056 &mut self,
9057 to_character_id: Option<uuid::Uuid>,
9058 to_name: String,
9059 amount_copper: u64,
9060 ) -> anyhow::Result<()> {
9061 let Some(panel) = self.state.bank_panel.clone() else {
9062 return Ok(());
9063 };
9064 self.seq += 1;
9065 self.session
9066 .submit_intent(Intent::BankTransfer {
9067 entity_id: self.state.entity_id,
9068 npc_id: panel.npc_id,
9069 to_character_id,
9070 to_name,
9071 amount_copper,
9072 seq: self.seq,
9073 })
9074 .await?;
9075 self.state.intents_sent += 1;
9076 Ok(())
9077 }
9078
9079 pub fn bank_menu_move(&mut self, delta: i32) {
9080 let n = self.state.bank_menu_options().len();
9081 if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
9082 return;
9083 }
9084 let idx = self.state.bank_menu_index as i32;
9085 self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9086 }
9087
9088 pub fn storage_menu_move(&mut self, delta: i32) {
9089 let n = self.state.storage_menu_options().len();
9090 if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
9091 return;
9092 }
9093 let idx = self.state.storage_menu_index as i32;
9094 self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9095 }
9096
9097 pub fn storage_pick_move(&mut self, delta: i32) {
9098 let n = match &self.state.storage_ui_mode {
9099 StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
9100 StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
9101 self.state.storage_vault_options().len()
9102 }
9103 StorageUiMode::Menu
9104 | StorageUiMode::StoreAmount { .. }
9105 | StorageUiMode::TakeAmount { .. }
9106 | StorageUiMode::ShipAmount { .. } => 0,
9107 };
9108 if n == 0 {
9109 return;
9110 }
9111 match &mut self.state.storage_ui_mode {
9112 StorageUiMode::StorePick { index }
9113 | StorageUiMode::TakePick { index }
9114 | StorageUiMode::ShipPick { index, .. } => {
9115 *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9116 }
9117 StorageUiMode::Menu
9118 | StorageUiMode::StoreAmount { .. }
9119 | StorageUiMode::TakeAmount { .. }
9120 | StorageUiMode::ShipAmount { .. } => {}
9121 }
9122 }
9123
9124 pub fn storage_ui_back(&mut self) {
9125 self.state.storage_ui_mode = match &self.state.storage_ui_mode {
9126 StorageUiMode::StoreAmount { pick_index, .. } => {
9127 StorageUiMode::StorePick { index: *pick_index }
9128 }
9129 StorageUiMode::TakeAmount { pick_index, .. } => {
9130 StorageUiMode::TakePick { index: *pick_index }
9131 }
9132 StorageUiMode::ShipAmount {
9133 dest_building_id,
9134 dest_label,
9135 pick_index,
9136 ..
9137 } => StorageUiMode::ShipPick {
9138 dest_building_id: dest_building_id.clone(),
9139 dest_label: dest_label.clone(),
9140 index: *pick_index,
9141 },
9142 StorageUiMode::StorePick { .. }
9143 | StorageUiMode::TakePick { .. }
9144 | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
9145 StorageUiMode::Menu => StorageUiMode::Menu,
9146 };
9147 }
9148
9149 pub fn storage_amount_append_char(&mut self, c: char) {
9150 match &mut self.state.storage_ui_mode {
9151 StorageUiMode::StoreAmount { input, .. }
9152 | StorageUiMode::TakeAmount { input, .. }
9153 | StorageUiMode::ShipAmount { input, .. } => {
9154 if c.is_ascii_digit() && input.len() < 8 {
9155 input.push(c);
9156 }
9157 }
9158 _ => {}
9159 }
9160 }
9161
9162 pub fn storage_amount_backspace(&mut self) {
9163 match &mut self.state.storage_ui_mode {
9164 StorageUiMode::StoreAmount { input, .. }
9165 | StorageUiMode::TakeAmount { input, .. }
9166 | StorageUiMode::ShipAmount { input, .. } => {
9167 input.pop();
9168 }
9169 _ => {}
9170 }
9171 }
9172
9173 pub fn storage_ui_typing(&self) -> bool {
9174 matches!(
9175 self.state.storage_ui_mode,
9176 StorageUiMode::StoreAmount { .. }
9177 | StorageUiMode::TakeAmount { .. }
9178 | StorageUiMode::ShipAmount { .. }
9179 )
9180 }
9181
9182 pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
9183 match self.state.storage_ui_mode.clone() {
9184 StorageUiMode::Menu => {
9185 let index = self.state.storage_menu_index;
9186 match index {
9187 0 => {
9188 let opts = self.state.storage_store_options();
9189 if opts.is_empty() {
9190 self.state.push_log("Nothing loose to store.");
9191 return Ok(());
9192 }
9193 self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
9194 }
9195 1 => {
9196 let opts = self.state.storage_vault_options();
9197 if opts.is_empty() {
9198 self.state.push_log("Vault is empty.");
9199 return Ok(());
9200 }
9201 self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
9202 }
9203 n => {
9204 let dest = self
9205 .state
9206 .storage_panel
9207 .as_ref()
9208 .and_then(|p| p.ship_destinations.get(n - 2))
9209 .cloned();
9210 let Some(dest) = dest else {
9211 return Ok(());
9212 };
9213 let opts = self.state.storage_vault_options();
9214 if opts.is_empty() {
9215 self.state.push_log("Vault is empty — nothing to ship.");
9216 return Ok(());
9217 }
9218 self.state.storage_ui_mode = StorageUiMode::ShipPick {
9219 dest_building_id: dest.building_id,
9220 dest_label: dest.label,
9221 index: 0,
9222 };
9223 }
9224 }
9225 }
9226 StorageUiMode::StorePick { index } => {
9227 let opts = self.state.storage_store_options();
9228 let Some(opt) = opts.get(index) else {
9229 self.state.push_log("Nothing loose to store.");
9230 self.state.storage_ui_mode = StorageUiMode::Menu;
9231 return Ok(());
9232 };
9233 self.state.storage_ui_mode = StorageUiMode::StoreAmount {
9234 pick_index: index,
9235 item_instance_id: opt.item_instance_id,
9236 label: opt.label.clone(),
9237 max_qty: opt.quantity.max(1),
9238 input: String::new(),
9239 };
9240 }
9241 StorageUiMode::TakePick { index } => {
9242 let opts = self.state.storage_vault_options();
9243 let Some(opt) = opts.get(index) else {
9244 self.state.push_log("Vault is empty.");
9245 self.state.storage_ui_mode = StorageUiMode::Menu;
9246 return Ok(());
9247 };
9248 self.state.storage_ui_mode = StorageUiMode::TakeAmount {
9249 pick_index: index,
9250 item_instance_id: opt.item_instance_id,
9251 label: opt.label.clone(),
9252 max_qty: opt.quantity.max(1),
9253 input: String::new(),
9254 };
9255 }
9256 StorageUiMode::ShipPick {
9257 dest_building_id,
9258 dest_label,
9259 index,
9260 } => {
9261 let opts = self.state.storage_vault_options();
9262 let Some(opt) = opts.get(index) else {
9263 self.state.push_log("Vault is empty — nothing to ship.");
9264 self.state.storage_ui_mode = StorageUiMode::Menu;
9265 return Ok(());
9266 };
9267 self.state.storage_ui_mode = StorageUiMode::ShipAmount {
9268 dest_building_id,
9269 dest_label,
9270 pick_index: index,
9271 item_instance_id: opt.item_instance_id,
9272 label: opt.label.clone(),
9273 max_qty: opt.quantity.max(1),
9274 input: String::new(),
9275 };
9276 }
9277 StorageUiMode::StoreAmount {
9278 item_instance_id,
9279 max_qty,
9280 input,
9281 ..
9282 } => {
9283 let Some(qty) = parse_storage_quantity(&input) else {
9284 self.state.push_log("Enter a quantity (blank or 0 = all).");
9285 return Ok(());
9286 };
9287 let qty = qty.map(|n| n.min(max_qty).max(1));
9288 self.storage_store(item_instance_id, qty).await?;
9289 self.state.storage_ui_mode = StorageUiMode::Menu;
9290 }
9291 StorageUiMode::TakeAmount {
9292 item_instance_id,
9293 max_qty,
9294 input,
9295 ..
9296 } => {
9297 let Some(qty) = parse_storage_quantity(&input) else {
9298 self.state.push_log("Enter a quantity (blank or 0 = all).");
9299 return Ok(());
9300 };
9301 let qty = qty.map(|n| n.min(max_qty).max(1));
9302 self.storage_take(item_instance_id, qty).await?;
9303 self.state.storage_ui_mode = StorageUiMode::Menu;
9304 }
9305 StorageUiMode::ShipAmount {
9306 dest_building_id,
9307 item_instance_id,
9308 max_qty,
9309 input,
9310 ..
9311 } => {
9312 let Some(qty) = parse_storage_quantity(&input) else {
9313 self.state.push_log("Enter a quantity (blank or 0 = all).");
9314 return Ok(());
9315 };
9316 let qty = qty.map(|n| n.min(max_qty).max(1));
9317 self.storage_ship(dest_building_id, item_instance_id, qty)
9318 .await?;
9319 self.state.storage_ui_mode = StorageUiMode::Menu;
9320 }
9321 }
9322 Ok(())
9323 }
9324
9325 pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
9326 match self.state.bank_ui_mode.clone() {
9327 BankUiMode::Menu => {
9328 let choice = self
9329 .state
9330 .bank_menu_options()
9331 .get(self.state.bank_menu_index)
9332 .copied()
9333 .unwrap_or("Deposit…");
9334 match choice {
9335 "Withdraw…" => {
9336 self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
9337 input: String::new(),
9338 };
9339 }
9340 "Deposit all" => self.bank_deposit(0).await?,
9341 "Withdraw all" => self.bank_withdraw(0).await?,
9342 "Transfer…" => {
9343 self.state.bank_ui_mode = BankUiMode::TransferName {
9344 input: String::new(),
9345 };
9346 }
9347 _ => {
9348 self.state.bank_ui_mode = BankUiMode::DepositAmount {
9349 input: String::new(),
9350 };
9351 }
9352 }
9353 }
9354 BankUiMode::DepositAmount { input } => {
9355 let Some(amount) = parse_bank_copper_amount(&input) else {
9356 self.state
9357 .push_log("Enter a copper amount (blank or 0 = everything on person).");
9358 return Ok(());
9359 };
9360 self.bank_deposit(amount).await?;
9361 self.state.bank_ui_mode = BankUiMode::Menu;
9362 }
9363 BankUiMode::WithdrawAmount { input } => {
9364 let Some(amount) = parse_bank_copper_amount(&input) else {
9365 self.state
9366 .push_log("Enter a copper amount (blank or 0 = full ledger).");
9367 return Ok(());
9368 };
9369 self.bank_withdraw(amount).await?;
9370 self.state.bank_ui_mode = BankUiMode::Menu;
9371 }
9372 BankUiMode::TransferName { input } => {
9373 let name = input.trim().to_string();
9374 if name.is_empty() {
9375 self.state.push_log("Enter the recipient character name.");
9376 return Ok(());
9377 }
9378 self.state.bank_ui_mode = BankUiMode::TransferAmount {
9379 to_name: name,
9380 input: String::new(),
9381 };
9382 }
9383 BankUiMode::TransferAmount { to_name, input } => {
9384 let amount: u64 = match input.trim().parse() {
9385 Ok(v) if v > 0 => v,
9386 _ => {
9387 self.state
9388 .push_log("Enter a positive copper amount to transfer.");
9389 return Ok(());
9390 }
9391 };
9392 self.bank_transfer(None, to_name, amount).await?;
9393 self.state.bank_ui_mode = BankUiMode::Menu;
9394 }
9395 }
9396 Ok(())
9397 }
9398
9399 pub fn bank_transfer_back(&mut self) {
9400 match &self.state.bank_ui_mode {
9401 BankUiMode::TransferAmount { to_name, .. } => {
9402 self.state.bank_ui_mode = BankUiMode::TransferName {
9403 input: to_name.clone(),
9404 };
9405 }
9406 BankUiMode::TransferName { .. }
9407 | BankUiMode::DepositAmount { .. }
9408 | BankUiMode::WithdrawAmount { .. } => {
9409 self.state.bank_ui_mode = BankUiMode::Menu;
9410 }
9411 BankUiMode::Menu => {}
9412 }
9413 }
9414
9415 pub fn bank_transfer_append_char(&mut self, c: char) {
9416 match &mut self.state.bank_ui_mode {
9417 BankUiMode::TransferName { input } => {
9418 if input.len() < 32 && !c.is_control() {
9419 input.push(c);
9420 }
9421 }
9422 BankUiMode::DepositAmount { input }
9423 | BankUiMode::WithdrawAmount { input }
9424 | BankUiMode::TransferAmount { input, .. } => {
9425 if c.is_ascii_digit() && input.len() < 12 {
9426 input.push(c);
9427 }
9428 }
9429 BankUiMode::Menu => {}
9430 }
9431 }
9432
9433 pub fn bank_transfer_backspace(&mut self) {
9434 match &mut self.state.bank_ui_mode {
9435 BankUiMode::TransferName { input }
9436 | BankUiMode::DepositAmount { input }
9437 | BankUiMode::WithdrawAmount { input }
9438 | BankUiMode::TransferAmount { input, .. } => {
9439 input.pop();
9440 }
9441 BankUiMode::Menu => {}
9442 }
9443 }
9444
9445 pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
9446 let npc_id = self.state.bank_panel.as_ref().map(|p| p.npc_id.clone());
9447 self.state.clear_bank_panel();
9448 if let Some(npc_id) = npc_id {
9449 self.seq += 1;
9450 self.session
9451 .submit_intent(Intent::BankClose {
9452 entity_id: self.state.entity_id,
9453 npc_id,
9454 seq: self.seq,
9455 })
9456 .await?;
9457 self.state.intents_sent += 1;
9458 }
9459 Ok(())
9460 }
9461
9462 pub async fn storage_store(
9463 &mut self,
9464 item_instance_id: uuid::Uuid,
9465 quantity: Option<u32>,
9466 ) -> anyhow::Result<()> {
9467 let Some(panel) = self.state.storage_panel.clone() else {
9468 return Ok(());
9469 };
9470 self.seq += 1;
9471 self.session
9472 .submit_intent(Intent::StorageStore {
9473 entity_id: self.state.entity_id,
9474 npc_id: panel.npc_id,
9475 item_instance_id,
9476 quantity,
9477 seq: self.seq,
9478 })
9479 .await?;
9480 self.state.intents_sent += 1;
9481 Ok(())
9482 }
9483
9484 pub async fn storage_take(
9485 &mut self,
9486 item_instance_id: uuid::Uuid,
9487 quantity: Option<u32>,
9488 ) -> anyhow::Result<()> {
9489 let Some(panel) = self.state.storage_panel.clone() else {
9490 return Ok(());
9491 };
9492 self.seq += 1;
9493 self.session
9494 .submit_intent(Intent::StorageTake {
9495 entity_id: self.state.entity_id,
9496 npc_id: panel.npc_id,
9497 item_instance_id,
9498 quantity,
9499 seq: self.seq,
9500 })
9501 .await?;
9502 self.state.intents_sent += 1;
9503 Ok(())
9504 }
9505
9506 pub async fn storage_ship(
9507 &mut self,
9508 dest_building_id: String,
9509 item_instance_id: uuid::Uuid,
9510 quantity: Option<u32>,
9511 ) -> anyhow::Result<()> {
9512 let Some(panel) = self.state.storage_panel.clone() else {
9513 return Ok(());
9514 };
9515 self.seq += 1;
9516 self.session
9517 .submit_intent(Intent::StorageShip {
9518 entity_id: self.state.entity_id,
9519 npc_id: panel.npc_id,
9520 dest_building_id,
9521 item_instance_id,
9522 quantity,
9523 seq: self.seq,
9524 })
9525 .await?;
9526 self.state.intents_sent += 1;
9527 Ok(())
9528 }
9529
9530 pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
9531 let npc_id = self.state.storage_panel.as_ref().map(|p| p.npc_id.clone());
9532 self.state.clear_storage_panel();
9533 if let Some(npc_id) = npc_id {
9534 self.seq += 1;
9535 self.session
9536 .submit_intent(Intent::StorageClose {
9537 entity_id: self.state.entity_id,
9538 npc_id,
9539 seq: self.seq,
9540 })
9541 .await?;
9542 self.state.intents_sent += 1;
9543 }
9544 Ok(())
9545 }
9546
9547 pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
9548 let npc_id = self.state.market_panel.as_ref().map(|p| p.npc_id.clone());
9549 self.state.clear_market_panel();
9550 if let Some(npc_id) = npc_id {
9551 self.seq += 1;
9552 self.session
9553 .submit_intent(Intent::MarketClose {
9554 entity_id: self.state.entity_id,
9555 npc_id,
9556 seq: self.seq,
9557 })
9558 .await?;
9559 self.state.intents_sent += 1;
9560 }
9561 Ok(())
9562 }
9563
9564 pub fn market_move_selection(&mut self, delta: i32) {
9565 let indices = self.state.market_filtered_listing_indices();
9566 let n = indices.len();
9567 if n == 0 {
9568 self.state.market_menu_index = 0;
9569 return;
9570 }
9571 let cur = self.state.market_menu_index as i32;
9572 self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
9573 }
9574
9575 pub fn market_page_selection(&mut self, pages: i32) {
9576 let indices = self.state.market_filtered_listing_indices();
9577 let n = indices.len();
9578 if n == 0 {
9579 self.state.market_menu_index = 0;
9580 return;
9581 }
9582 self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
9583 }
9584
9585 pub fn market_list_page(&mut self, pages: i32) {
9586 match &self.state.market_ui_mode {
9587 MarketUiMode::ListSource { index } => {
9588 let n = self.state.market_list_source_options().len();
9589 if n == 0 {
9590 return;
9591 }
9592 let next = page_list_index(*index, pages, n);
9593 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9594 }
9595 MarketUiMode::ListPricingMode { index, .. } => {
9596 let next = page_list_index(*index, pages, 2);
9597 if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
9598 {
9599 *index = next;
9600 }
9601 }
9602 MarketUiMode::ListPick { source, index } => {
9603 let opts = self.state.market_list_item_options(source);
9604 let n = opts.len();
9605 if n == 0 {
9606 return;
9607 }
9608 let next = page_list_index(*index, pages, n);
9609 self.state.market_ui_mode = MarketUiMode::ListPick {
9610 source: source.clone(),
9611 index: next,
9612 };
9613 }
9614 _ => {}
9615 }
9616 }
9617
9618 pub fn market_cycle_category(&mut self, delta: i32) {
9619 let groups = self.state.market_available_category_groups();
9620 let mut labels: Vec<Option<&'static str>> = vec![None];
9622 labels.extend(groups.into_iter().map(Some));
9623 let n = labels.len() as i32;
9624 let cur = labels
9625 .iter()
9626 .position(|g| *g == self.state.market_category_filter)
9627 .unwrap_or(0) as i32;
9628 let next = (cur + delta).rem_euclid(n) as usize;
9629 self.state.market_category_filter = labels[next];
9630 self.state.market_menu_index = 0;
9631 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9632 let source = source.clone();
9633 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9634 }
9635 }
9636
9637 pub fn focus_market_filter(&mut self) {
9638 self.state.market_filter_focused = true;
9639 }
9640
9641 pub fn append_market_filter_char(&mut self, ch: char) {
9642 if !self.state.market_filter_focused {
9643 return;
9644 }
9645 if ch.is_control() {
9646 return;
9647 }
9648 if self.state.market_filter.len() < 48 {
9649 self.state.market_filter.push(ch);
9650 self.state.market_menu_index = 0;
9651 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9652 let source = source.clone();
9653 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9654 }
9655 }
9656 }
9657
9658 pub fn market_filter_backspace(&mut self) {
9659 if !self.state.market_filter_focused {
9660 return;
9661 }
9662 self.state.market_filter.pop();
9663 self.state.market_menu_index = 0;
9664 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9665 let source = source.clone();
9666 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9667 }
9668 }
9669
9670 pub fn clear_or_blur_market_filter(&mut self) -> bool {
9672 if self.state.market_filter_focused {
9673 if !self.state.market_filter.is_empty() {
9674 self.state.market_filter.clear();
9675 self.state.market_menu_index = 0;
9676 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9677 let source = source.clone();
9678 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9679 }
9680 return true;
9681 }
9682 self.state.market_filter_focused = false;
9683 return true;
9684 }
9685 if !self.state.market_filter.is_empty() {
9686 self.state.market_filter.clear();
9687 self.state.market_menu_index = 0;
9688 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9689 let source = source.clone();
9690 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9691 }
9692 return true;
9693 }
9694 false
9695 }
9696
9697 pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
9698 if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
9699 return self.market_confirm_buy(listing_id, qty).await;
9700 }
9701 let Some(panel) = self.state.market_panel.clone() else {
9702 return Ok(());
9703 };
9704 let indices = self.state.market_filtered_listing_indices();
9705 let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
9706 return Ok(());
9707 };
9708 let Some(listing) = panel.listings.get(raw_idx) else {
9709 return Ok(());
9710 };
9711 if listing.mine {
9712 self.seq += 1;
9713 self.session
9714 .submit_intent(Intent::MarketDelist {
9715 entity_id: self.state.entity_id,
9716 npc_id: panel.npc_id.clone(),
9717 listing_id: listing.listing_id,
9718 dest: flatland_protocol::GoodsLocation::Person,
9719 seq: self.seq,
9720 })
9721 .await?;
9722 self.state.intents_sent += 1;
9723 return Ok(());
9724 }
9725 if listing.npc_price {
9726 self.state
9727 .push_log("NPC-price listings are bought by merchants only.");
9728 return Ok(());
9729 }
9730 let qty = 1u32.min(listing.quantity).max(1);
9731 let line = listing.unit_price_copper.saturating_mul(qty as u64);
9732 self.state.market_buy_confirm = Some((
9733 listing.listing_id,
9734 qty,
9735 listing.unit_price_copper,
9736 line,
9737 listing.display_name.clone(),
9738 ));
9739 Ok(())
9740 }
9741
9742 pub async fn market_confirm_buy(
9743 &mut self,
9744 listing_id: uuid::Uuid,
9745 quantity: u32,
9746 ) -> anyhow::Result<()> {
9747 let Some(panel) = self.state.market_panel.clone() else {
9748 self.state.market_buy_confirm = None;
9749 return Ok(());
9750 };
9751 self.state.market_buy_confirm = None;
9752 self.seq += 1;
9753 self.session
9754 .submit_intent(Intent::MarketBuy {
9755 entity_id: self.state.entity_id,
9756 npc_id: panel.npc_id,
9757 listing_id,
9758 quantity,
9759 dest: flatland_protocol::GoodsLocation::Person,
9760 seq: self.seq,
9761 })
9762 .await?;
9763 self.state.intents_sent += 1;
9764 Ok(())
9765 }
9766
9767 pub fn market_begin_list(&mut self) {
9769 if self.state.market_panel.is_none() {
9770 return;
9771 }
9772 let sources = self.state.market_list_source_options();
9773 if sources.is_empty() {
9774 self.state.push_log("Nothing to list from.");
9775 return;
9776 }
9777 if sources.len() == 1 {
9779 let (source, _) = sources[0].clone();
9780 let opts = self.state.market_list_item_options(&source);
9781 if opts.is_empty() {
9782 self.state.push_log("Nothing loose to list.");
9783 return;
9784 }
9785 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9786 self.state.market_buy_confirm = None;
9787 return;
9788 }
9789 self.state.market_buy_confirm = None;
9790 self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
9791 }
9792
9793 pub fn market_ui_back(&mut self) {
9794 self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
9795 MarketUiMode::Browse => MarketUiMode::Browse,
9796 MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
9797 MarketUiMode::ListPick { .. } => {
9798 if self.state.market_list_source_options().len() <= 1 {
9799 MarketUiMode::Browse
9800 } else {
9801 MarketUiMode::ListSource { index: 0 }
9802 }
9803 }
9804 MarketUiMode::ListAmount {
9805 source, pick_index, ..
9806 } => MarketUiMode::ListPick {
9807 source,
9808 index: pick_index,
9809 },
9810 MarketUiMode::ListPricingMode {
9811 source,
9812 item_instance_id,
9813 template_id,
9814 label,
9815 max_qty,
9816 quantity,
9817 pick_index,
9818 ..
9819 } => {
9820 let input = quantity.map(|q| q.to_string()).unwrap_or_default();
9821 MarketUiMode::ListAmount {
9822 source,
9823 pick_index,
9824 item_instance_id,
9825 template_id,
9826 label,
9827 max_qty,
9828 input,
9829 }
9830 }
9831 MarketUiMode::ListPrice {
9832 source,
9833 pick_index,
9834 item_instance_id,
9835 template_id,
9836 label,
9837 max_qty,
9838 quantity,
9839 ..
9840 } => MarketUiMode::ListPricingMode {
9841 source,
9842 pick_index,
9843 item_instance_id,
9844 template_id,
9845 label,
9846 quantity,
9847 max_qty,
9848 index: 1,
9849 },
9850 };
9851 }
9852
9853 pub fn market_list_move(&mut self, delta: i32) {
9854 match &self.state.market_ui_mode {
9855 MarketUiMode::ListSource { index } => {
9856 let n = self.state.market_list_source_options().len();
9857 if n == 0 {
9858 return;
9859 }
9860 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9861 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9862 }
9863 MarketUiMode::ListPricingMode { index, .. } => {
9864 let next = (*index as i32 + delta).rem_euclid(2) as usize;
9865 if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
9866 {
9867 *index = next;
9868 }
9869 }
9870 MarketUiMode::ListPick { source, index } => {
9871 let opts = self.state.market_list_item_options(source);
9872 let n = opts.len();
9873 if n == 0 {
9874 return;
9875 }
9876 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9877 self.state.market_ui_mode = MarketUiMode::ListPick {
9878 source: source.clone(),
9879 index: next,
9880 };
9881 }
9882 _ => {}
9883 }
9884 }
9885
9886 pub fn market_list_amount_append_char(&mut self, c: char) {
9887 if !c.is_ascii_digit() {
9888 return;
9889 }
9890 match &mut self.state.market_ui_mode {
9891 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
9892 if input.len() < 12 {
9893 input.push(c);
9894 }
9895 }
9896 _ => {}
9897 }
9898 }
9899
9900 pub fn market_list_amount_backspace(&mut self) {
9901 match &mut self.state.market_ui_mode {
9902 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
9903 input.pop();
9904 }
9905 _ => {}
9906 }
9907 }
9908
9909 pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
9910 match self.state.market_ui_mode.clone() {
9911 MarketUiMode::Browse => Ok(()),
9912 MarketUiMode::ListSource { index } => {
9913 let sources = self.state.market_list_source_options();
9914 let Some((source, _)) = sources.get(index).cloned() else {
9915 return Ok(());
9916 };
9917 let opts = self.state.market_list_item_options(&source);
9918 if opts.is_empty() {
9919 self.state.push_log("Nothing to list from that source.");
9920 return Ok(());
9921 }
9922 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9923 Ok(())
9924 }
9925 MarketUiMode::ListPick { source, index } => {
9926 let opts = self.state.market_list_item_options(&source);
9927 let Some(opt) = opts.get(index) else {
9928 self.state.push_log("Nothing to list.");
9929 self.state.market_ui_mode = MarketUiMode::Browse;
9930 return Ok(());
9931 };
9932 self.state.market_ui_mode = MarketUiMode::ListAmount {
9933 source,
9934 pick_index: index,
9935 item_instance_id: opt.item_instance_id,
9936 template_id: opt.template_id.clone(),
9937 label: opt.label.clone(),
9938 max_qty: opt.quantity.max(1),
9939 input: String::new(),
9940 };
9941 Ok(())
9942 }
9943 MarketUiMode::ListAmount {
9944 source,
9945 pick_index,
9946 item_instance_id,
9947 template_id,
9948 label,
9949 max_qty,
9950 input,
9951 ..
9952 } => {
9953 let Some(qty_opt) = parse_storage_quantity(&input) else {
9954 self.state.push_log("Enter a quantity (blank = all).");
9955 return Ok(());
9956 };
9957 if let Some(q) = qty_opt {
9958 if q > max_qty {
9959 self.state.push_log(format!("Only {max_qty} available."));
9960 return Ok(());
9961 }
9962 }
9963 self.state.market_ui_mode = MarketUiMode::ListPricingMode {
9964 source,
9965 pick_index,
9966 item_instance_id,
9967 template_id,
9968 label,
9969 quantity: qty_opt,
9970 max_qty,
9971 index: 0,
9972 };
9973 Ok(())
9974 }
9975 MarketUiMode::ListPricingMode {
9976 source,
9977 pick_index,
9978 item_instance_id,
9979 template_id,
9980 label,
9981 quantity,
9982 max_qty,
9983 index,
9984 } => {
9985 if index == 0 {
9986 if self
9987 .state
9988 .npc_market_dump_unit_estimate(&template_id)
9989 .is_none()
9990 {
9991 self.state
9992 .push_log("That item has no NPC value — use a fixed price instead.");
9993 return Ok(());
9994 }
9995 return self
9996 .submit_market_list_intent(
9997 source,
9998 item_instance_id,
9999 quantity,
10000 0,
10001 true,
10002 &label,
10003 )
10004 .await;
10005 }
10006 self.state.market_ui_mode = MarketUiMode::ListPrice {
10007 source,
10008 pick_index,
10009 item_instance_id,
10010 template_id,
10011 label,
10012 quantity,
10013 max_qty,
10014 input: String::new(),
10015 };
10016 Ok(())
10017 }
10018 MarketUiMode::ListPrice {
10019 source,
10020 item_instance_id,
10021 label,
10022 quantity,
10023 input,
10024 ..
10025 } => {
10026 let price = input.trim().parse::<u64>().unwrap_or(0);
10027 if price == 0 {
10028 self.state
10029 .push_log("Enter a unit price of at least 1 copper.");
10030 return Ok(());
10031 }
10032 self.submit_market_list_intent(
10033 source,
10034 item_instance_id,
10035 quantity,
10036 price,
10037 false,
10038 &label,
10039 )
10040 .await
10041 }
10042 }
10043 }
10044
10045 async fn submit_market_list_intent(
10046 &mut self,
10047 source: MarketListSourceKind,
10048 item_instance_id: uuid::Uuid,
10049 quantity: Option<u32>,
10050 unit_price_copper: u64,
10051 npc_price: bool,
10052 label: &str,
10053 ) -> anyhow::Result<()> {
10054 let Some(panel) = self.state.market_panel.clone() else {
10055 self.state.market_ui_mode = MarketUiMode::Browse;
10056 return Ok(());
10057 };
10058 let goods = match source {
10059 MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
10060 MarketListSourceKind::TownStorage { building_id } => {
10061 flatland_protocol::GoodsLocation::TownStorage { building_id }
10062 }
10063 };
10064 self.seq += 1;
10065 self.session
10066 .submit_intent(Intent::MarketList {
10067 entity_id: self.state.entity_id,
10068 npc_id: panel.npc_id,
10069 source: goods,
10070 item_instance_id,
10071 quantity,
10072 unit_price_copper,
10073 npc_price,
10074 seq: self.seq,
10075 })
10076 .await?;
10077 self.state.intents_sent += 1;
10078 if npc_price {
10079 self.state
10080 .push_log(format!("Listing {label} at NPC price…"));
10081 } else {
10082 self.state
10083 .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
10084 }
10085 self.state.market_ui_mode = MarketUiMode::Browse;
10086 Ok(())
10087 }
10088
10089 pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
10091 let return_to_verbs = self.state.npc_verb_target.is_some();
10092 self.close_shop_menu().await?;
10093 if return_to_verbs {
10094 self.state.show_npc_verb_menu = true;
10095 }
10096 Ok(())
10097 }
10098
10099 pub fn shop_tab_toggle(&mut self) {
10100 self.state.shop_tab = match self.state.shop_tab {
10101 ShopTab::Buy => ShopTab::Sell,
10102 ShopTab::Sell => ShopTab::Buy,
10103 };
10104 self.state.shop_menu_index = 0;
10105 if self.state.shop_tab == ShopTab::Sell {
10106 self.state.shop_quantity_set_max();
10107 }
10108 self.state.clamp_shop_selection();
10109 }
10110
10111 pub fn shop_menu_move(&mut self, delta: i32) {
10112 self.state.shop_menu_move(delta);
10113 }
10114
10115 pub fn shop_quantity_adjust(&mut self, delta: i32) {
10116 self.state.shop_quantity_adjust(delta);
10117 }
10118
10119 pub fn shop_quantity_set_max(&mut self) {
10120 self.state.shop_quantity_set_max();
10121 }
10122
10123 pub fn shop_quantity_set_min(&mut self) {
10124 self.state.shop_quantity_set_min();
10125 }
10126
10127 pub fn toggle_quest_menu(&mut self) {
10128 self.state.show_quest_menu = !self.state.show_quest_menu;
10129 if self.state.show_quest_menu {
10130 self.state.quest_menu_index = 0;
10131 self.state.quest_withdraw_confirm = false;
10132 self.state.show_workers_menu = false;
10133 }
10134 }
10135
10136 pub fn toggle_workers_menu(&mut self) {
10137 if self.state.show_workers_menu {
10138 self.close_workers_menu_ui();
10139 } else {
10140 self.state.show_workers_menu = true;
10141 self.state.workers_menu_index = 0;
10142 self.state.show_quest_menu = false;
10143 self.close_worker_give_picker();
10144 self.close_worker_give_target_picker();
10145 self.close_worker_take_picker();
10146 self.close_worker_teach_picker();
10147 self.cancel_worker_rename();
10148 }
10149 }
10150
10151 pub fn close_workers_menu_ui(&mut self) {
10153 self.state.show_workers_menu = false;
10154 self.cancel_worker_dismissal();
10155 self.close_worker_give_picker();
10156 self.close_worker_give_target_picker();
10157 self.close_worker_take_picker();
10158 self.close_worker_teach_picker();
10159 self.cancel_worker_rename();
10160 }
10161
10162 pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
10164 let Some(idx) = self
10165 .state
10166 .hired_workers
10167 .iter()
10168 .position(|w| w.instance_id == instance_id)
10169 else {
10170 anyhow::bail!("worker not found");
10171 };
10172 let label = self.state.hired_workers[idx].label.clone();
10173 self.state.show_workers_menu = true;
10174 self.state.workers_menu_index = idx;
10175 self.state.show_quest_menu = false;
10176 self.close_worker_give_picker();
10177 self.close_worker_give_target_picker();
10178 self.close_worker_take_picker();
10179 self.close_worker_teach_picker();
10180 self.cancel_worker_rename();
10181 self.set_worker_attending(instance_id, true).await?;
10182 self.state
10183 .push_log(format!("Managing {label} — job paused while menu is open"));
10184 Ok(())
10185 }
10186
10187 pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
10189 self.close_workers_menu_ui();
10190 self.release_worker_attend().await
10191 }
10192
10193 async fn set_worker_attending(
10194 &mut self,
10195 instance_id: &str,
10196 attending: bool,
10197 ) -> anyhow::Result<()> {
10198 if attending {
10199 if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
10200 return Ok(());
10201 }
10202 if let Some(prev) = self.state.attending_worker_instance_id.clone() {
10204 if prev != instance_id {
10205 self.send_attend_hired_worker(&prev, false).await?;
10206 }
10207 }
10208 self.send_attend_hired_worker(instance_id, true).await?;
10209 self.state.attending_worker_instance_id = Some(instance_id.to_string());
10210 } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
10211 self.send_attend_hired_worker(instance_id, false).await?;
10212 self.state.attending_worker_instance_id = None;
10213 }
10214 Ok(())
10215 }
10216
10217 pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
10218 let Some(id) = self.state.attending_worker_instance_id.take() else {
10219 return Ok(());
10220 };
10221 self.send_attend_hired_worker(&id, false).await
10222 }
10223
10224 async fn send_attend_hired_worker(
10225 &mut self,
10226 worker_instance_id: &str,
10227 attending: bool,
10228 ) -> anyhow::Result<()> {
10229 self.seq += 1;
10230 self.session
10231 .submit_intent(Intent::AttendHiredWorker {
10232 entity_id: self.state.entity_id,
10233 worker_instance_id: worker_instance_id.to_string(),
10234 attending,
10235 seq: self.seq,
10236 })
10237 .await?;
10238 self.state.intents_sent += 1;
10239 Ok(())
10240 }
10241
10242 pub fn workers_menu_move(&mut self, delta: i32) {
10243 let n = self.state.hired_workers.len();
10244 if n == 0 {
10245 return;
10246 }
10247 let idx = self.state.workers_menu_index as i32;
10248 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10249 }
10250
10251 pub fn toggle_workers_menu_compact(&mut self) {
10252 self.state.workers_menu_compact = !self.state.workers_menu_compact;
10253 let mut cfg = crate::client_config::ClientConfig::load();
10254 let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
10255 }
10256
10257 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
10258 let Some(worker) = self
10259 .state
10260 .hired_workers
10261 .get(self.state.workers_menu_index)
10262 .cloned()
10263 else {
10264 anyhow::bail!("no worker selected");
10265 };
10266 self.dismiss_worker_by_id(&worker.instance_id, &worker.label)
10267 .await
10268 }
10269
10270 pub fn request_worker_dismissal(&mut self) -> anyhow::Result<()> {
10272 let Some(worker) = self
10273 .state
10274 .hired_workers
10275 .get(self.state.workers_menu_index)
10276 .cloned()
10277 else {
10278 anyhow::bail!("no worker selected");
10279 };
10280 self.state.worker_dismiss_confirmation = Some(WorkerDismissConfirmation {
10281 worker_instance_id: worker.instance_id,
10282 worker_label: worker.label,
10283 });
10284 Ok(())
10285 }
10286
10287 pub fn cancel_worker_dismissal(&mut self) {
10288 self.state.worker_dismiss_confirmation = None;
10289 }
10290
10291 pub async fn confirm_worker_dismissal(&mut self) -> anyhow::Result<()> {
10292 let Some(confirm) = self.state.worker_dismiss_confirmation.clone() else {
10293 return Ok(());
10294 };
10295 self.dismiss_worker_by_id(&confirm.worker_instance_id, &confirm.worker_label)
10296 .await?;
10297 self.cancel_worker_dismissal();
10298 Ok(())
10299 }
10300
10301 async fn dismiss_worker_by_id(
10302 &mut self,
10303 worker_instance_id: &str,
10304 worker_label: &str,
10305 ) -> anyhow::Result<()> {
10306 self.seq += 1;
10307 self.session
10308 .submit_intent(Intent::DismissWorker {
10309 entity_id: self.state.entity_id,
10310 worker_instance_id: worker_instance_id.to_string(),
10311 seq: self.seq,
10312 })
10313 .await?;
10314 self.state.intents_sent += 1;
10315 self.state
10316 .hired_workers
10317 .retain(|w| w.instance_id != worker_instance_id);
10318 if self.state.workers_menu_index >= self.state.hired_workers.len() {
10319 self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
10320 }
10321 self.state
10322 .push_log(format!("Dismissed {worker_label}"));
10323 Ok(())
10324 }
10325
10326 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
10327 let Some(worker) = self
10328 .state
10329 .hired_workers
10330 .get(self.state.workers_menu_index)
10331 .cloned()
10332 else {
10333 anyhow::bail!("no worker selected");
10334 };
10335 let mode = match worker.mode {
10336 flatland_protocol::WorkerModeView::Companion => "defender",
10337 flatland_protocol::WorkerModeView::Defender => "job_loop",
10338 flatland_protocol::WorkerModeView::JobLoop => "idle",
10339 flatland_protocol::WorkerModeView::Idle => "companion",
10340 };
10341 self.seq += 1;
10342 self.session
10343 .submit_intent(Intent::SetWorkerMode {
10344 entity_id: self.state.entity_id,
10345 worker_instance_id: worker.instance_id,
10346 mode: mode.into(),
10347 seq: self.seq,
10348 })
10349 .await?;
10350 self.state.intents_sent += 1;
10351 Ok(())
10352 }
10353
10354 pub async fn workers_deliver_selected_to_storage(&mut self) -> anyhow::Result<()> {
10355 let Some(worker) = self
10356 .state
10357 .hired_workers
10358 .get(self.state.workers_menu_index)
10359 .cloned()
10360 else {
10361 anyhow::bail!("no worker selected");
10362 };
10363 if !matches!(
10364 worker.mode,
10365 flatland_protocol::WorkerModeView::Companion
10366 ) {
10367 anyhow::bail!("switch the worker to companion mode first");
10368 }
10369 if worker.step_label.starts_with("delivering to ")
10370 || worker.step_label == "returning to you"
10371 {
10372 anyhow::bail!("worker is already delivering to storage");
10373 }
10374 self.seq += 1;
10375 self.session
10376 .submit_intent(Intent::DeliverWorkerToNearestStorage {
10377 entity_id: self.state.entity_id,
10378 worker_instance_id: worker.instance_id.clone(),
10379 seq: self.seq,
10380 })
10381 .await?;
10382 self.state.intents_sent += 1;
10383 self.state.push_log(format!(
10384 "{} is delivering carried items to storage",
10385 worker.label
10386 ));
10387 Ok(())
10388 }
10389
10390 pub async fn workers_cancel_delivery_selected(&mut self) -> anyhow::Result<()> {
10391 let Some(worker) = self
10392 .state
10393 .hired_workers
10394 .get(self.state.workers_menu_index)
10395 .cloned()
10396 else {
10397 anyhow::bail!("no worker selected");
10398 };
10399 if !(worker.step_label.starts_with("delivering to ")
10400 || worker.step_label == "returning to you")
10401 {
10402 anyhow::bail!("worker has no active delivery");
10403 }
10404 self.seq += 1;
10405 self.session
10406 .submit_intent(Intent::CancelWorkerDelivery {
10407 entity_id: self.state.entity_id,
10408 worker_instance_id: worker.instance_id,
10409 seq: self.seq,
10410 })
10411 .await?;
10412 self.state.intents_sent += 1;
10413 self.state
10414 .push_log(format!("Canceled delivery for {}", worker.label));
10415 Ok(())
10416 }
10417
10418 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
10419 if self.state.hired_workers.is_empty() {
10420 return self.hire_worker_laborer().await;
10421 }
10422 self.workers_toggle_mode_selected().await
10423 }
10424
10425 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
10428 let row = self
10429 .state
10430 .inventory_selected_row()
10431 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
10432 .clone();
10433 if row.from != flatland_protocol::InventoryLocation::Root {
10434 anyhow::bail!("select a carried item to give");
10435 }
10436 let Some(instance_id) = row.stack.item_instance_id else {
10437 anyhow::bail!("that stack can't be given");
10438 };
10439 let options = self.nearby_worker_give_targets();
10440 if options.is_empty() {
10441 anyhow::bail!(
10442 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
10443 );
10444 }
10445 let item_label = row
10446 .stack
10447 .display_name
10448 .as_deref()
10449 .unwrap_or(&row.stack.template_id)
10450 .to_string();
10451 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
10452 item_instance_id: instance_id,
10453 item_label,
10454 quantity: None,
10455 options,
10456 });
10457 self.state.worker_give_target_picker_index = 0;
10458 self.state.show_worker_give_target_picker = true;
10459 self.state.show_inventory_menu = false;
10461 Ok(())
10462 }
10463
10464 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
10466 let (px, py, _) = self.state.player_position_with_z();
10467 let mut options: Vec<WorkerGiveTargetOption> = self
10468 .state
10469 .hired_workers
10470 .iter()
10471 .filter_map(|w| {
10472 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
10473 if dist > WORKER_GIVE_RANGE_M {
10474 return None;
10475 }
10476 Some(WorkerGiveTargetOption {
10477 instance_id: w.instance_id.clone(),
10478 label: w.label.clone(),
10479 distance_m: dist,
10480 })
10481 })
10482 .collect();
10483 options.sort_by(|a, b| {
10484 a.distance_m
10485 .partial_cmp(&b.distance_m)
10486 .unwrap_or(std::cmp::Ordering::Equal)
10487 });
10488 options
10489 }
10490
10491 pub fn close_worker_give_target_picker(&mut self) {
10492 self.state.show_worker_give_target_picker = false;
10493 self.state.worker_give_target_picker = None;
10494 self.state.worker_give_target_picker_index = 0;
10495 }
10496
10497 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
10498 let Some(picker) = &self.state.worker_give_target_picker else {
10499 return;
10500 };
10501 let n = picker.options.len();
10502 if n == 0 {
10503 return;
10504 }
10505 let idx = self.state.worker_give_target_picker_index as i32;
10506 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10507 }
10508
10509 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
10510 let Some(picker) = self.state.worker_give_target_picker.clone() else {
10511 anyhow::bail!("give target picker not open");
10512 };
10513 let Some(opt) = picker
10514 .options
10515 .get(self.state.worker_give_target_picker_index)
10516 .cloned()
10517 else {
10518 anyhow::bail!("no worker selected");
10519 };
10520 let Some(worker) = self
10521 .state
10522 .hired_workers
10523 .iter()
10524 .find(|w| w.instance_id == opt.instance_id)
10525 .cloned()
10526 else {
10527 self.close_worker_give_target_picker();
10528 anyhow::bail!("worker no longer hired");
10529 };
10530 self.give_item_to_worker(
10531 &worker.instance_id,
10532 &worker.label,
10533 worker.x,
10534 worker.y,
10535 picker.item_instance_id,
10536 &picker.item_label,
10537 picker.quantity,
10538 )
10539 .await?;
10540 self.close_worker_give_target_picker();
10541 Ok(())
10542 }
10543
10544 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
10546 self.open_worker_give_target_picker()
10547 }
10548
10549 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
10551 let Some(worker) = self
10552 .state
10553 .hired_workers
10554 .get(self.state.workers_menu_index)
10555 .cloned()
10556 else {
10557 anyhow::bail!("select a hired worker first");
10558 };
10559 let (px, py, _) = self.state.player_position_with_z();
10560 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10561 if dist > WORKER_GIVE_RANGE_M {
10562 anyhow::bail!(
10563 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
10564 worker.label
10565 );
10566 }
10567 let options = self.state.giveable_inventory_options();
10568 if options.is_empty() {
10569 anyhow::bail!("nothing in inventory to give");
10570 }
10571 self.state.worker_give_picker = Some(WorkerGivePicker {
10572 worker_instance_id: worker.instance_id,
10573 worker_label: worker.label,
10574 options,
10575 });
10576 self.state.worker_give_picker_index = 0;
10577 self.state.show_worker_give_picker = true;
10578 Ok(())
10579 }
10580
10581 pub fn close_worker_give_picker(&mut self) {
10582 self.state.show_worker_give_picker = false;
10583 self.state.worker_give_picker = None;
10584 self.state.worker_give_picker_index = 0;
10585 }
10586
10587 pub fn worker_give_picker_move(&mut self, delta: i32) {
10588 let Some(picker) = &self.state.worker_give_picker else {
10589 return;
10590 };
10591 let n = picker.options.len();
10592 if n == 0 {
10593 return;
10594 }
10595 let idx = self.state.worker_give_picker_index as i32;
10596 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10597 }
10598
10599 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
10601 let Some(picker) = self.state.worker_give_picker.clone() else {
10602 anyhow::bail!("give picker not open");
10603 };
10604 let Some(opt) = picker
10605 .options
10606 .get(self.state.worker_give_picker_index)
10607 .cloned()
10608 else {
10609 anyhow::bail!("no item selected");
10610 };
10611 let Some(worker) = self
10612 .state
10613 .hired_workers
10614 .iter()
10615 .find(|w| w.instance_id == picker.worker_instance_id)
10616 .cloned()
10617 else {
10618 self.close_worker_give_picker();
10619 anyhow::bail!("worker no longer hired");
10620 };
10621 self.give_item_to_worker(
10622 &worker.instance_id,
10623 &worker.label,
10624 worker.x,
10625 worker.y,
10626 opt.item_instance_id,
10627 &opt.label,
10628 None,
10629 )
10630 .await?;
10631 let options = self.state.giveable_inventory_options();
10633 if options.is_empty() {
10634 self.close_worker_give_picker();
10635 } else {
10636 self.state.worker_give_picker = Some(WorkerGivePicker {
10637 worker_instance_id: picker.worker_instance_id,
10638 worker_label: picker.worker_label,
10639 options,
10640 });
10641 if self.state.worker_give_picker_index
10642 >= self
10643 .state
10644 .worker_give_picker
10645 .as_ref()
10646 .map(|p| p.options.len())
10647 .unwrap_or(0)
10648 {
10649 self.state.worker_give_picker_index = self
10650 .state
10651 .worker_give_picker
10652 .as_ref()
10653 .map(|p| p.options.len().saturating_sub(1))
10654 .unwrap_or(0);
10655 }
10656 }
10657 Ok(())
10658 }
10659
10660 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10662 let Some(worker) = self
10663 .state
10664 .hired_workers
10665 .get(self.state.workers_menu_index)
10666 .cloned()
10667 else {
10668 anyhow::bail!("select a hired worker first");
10669 };
10670 let (px, py, _) = self.state.player_position_with_z();
10671 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10672 if dist > WORKER_GIVE_RANGE_M {
10673 anyhow::bail!(
10674 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
10675 worker.label
10676 );
10677 }
10678 let options = self.state.teachable_blueprint_options(&worker);
10679 if options.is_empty() {
10680 anyhow::bail!("no recipes you know that {} still needs", worker.label);
10681 }
10682 self.state.worker_teach_picker = Some(WorkerTeachPicker {
10683 worker_instance_id: worker.instance_id,
10684 worker_label: worker.label,
10685 worker_level: worker.level,
10686 options,
10687 });
10688 self.state.worker_teach_picker_index = 0;
10689 self.state.show_worker_teach_picker = true;
10690 Ok(())
10691 }
10692
10693 pub fn close_worker_teach_picker(&mut self) {
10694 self.state.show_worker_teach_picker = false;
10695 self.state.worker_teach_picker = None;
10696 self.state.worker_teach_picker_index = 0;
10697 }
10698
10699 pub fn worker_teach_picker_move(&mut self, delta: i32) {
10700 let Some(picker) = &self.state.worker_teach_picker else {
10701 return;
10702 };
10703 let n = picker.options.len();
10704 if n == 0 {
10705 return;
10706 }
10707 let idx = self.state.worker_teach_picker_index as i32;
10708 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10709 }
10710
10711 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10712 let Some(picker) = self.state.worker_teach_picker.clone() else {
10713 anyhow::bail!("teach picker not open");
10714 };
10715 let Some(opt) = picker
10716 .options
10717 .get(self.state.worker_teach_picker_index)
10718 .cloned()
10719 else {
10720 anyhow::bail!("nothing selected");
10721 };
10722 if !opt.level_ok {
10723 anyhow::bail!(
10724 "{} needs level {} (is level {})",
10725 picker.worker_label,
10726 opt.min_level,
10727 opt.worker_level
10728 );
10729 }
10730 if !opt.can_afford {
10731 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
10732 }
10733 let Some(worker) = self
10734 .state
10735 .hired_workers
10736 .iter()
10737 .find(|w| w.instance_id == picker.worker_instance_id)
10738 .cloned()
10739 else {
10740 anyhow::bail!("worker gone");
10741 };
10742 let (px, py, _) = self.state.player_position_with_z();
10743 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10744 if dist > WORKER_GIVE_RANGE_M {
10745 anyhow::bail!("worker {} too far — stand next to them", worker.label);
10746 }
10747 self.seq += 1;
10748 self.session
10749 .submit_intent(Intent::TeachWorkerBlueprint {
10750 entity_id: self.state.entity_id,
10751 worker_instance_id: picker.worker_instance_id.clone(),
10752 blueprint_id: opt.blueprint_id.clone(),
10753 seq: self.seq,
10754 })
10755 .await?;
10756 self.state.intents_sent += 1;
10757 self.state.push_log(format!(
10758 "Teaching {} to {} ({} cp)",
10759 opt.label, picker.worker_label, opt.cost_copper
10760 ));
10761 self.close_worker_teach_picker();
10762 Ok(())
10763 }
10764
10765 async fn give_item_to_worker(
10766 &mut self,
10767 worker_instance_id: &str,
10768 worker_label: &str,
10769 worker_x: f32,
10770 worker_y: f32,
10771 item_instance_id: uuid::Uuid,
10772 item_label: &str,
10773 quantity: Option<u32>,
10774 ) -> anyhow::Result<()> {
10775 let (px, py, _) = self.state.player_position_with_z();
10776 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
10777 if dist > WORKER_GIVE_RANGE_M {
10778 anyhow::bail!("worker {worker_label} too far — stand next to them");
10779 }
10780 self.seq += 1;
10781 self.session
10782 .submit_intent(Intent::GiveWorkerItem {
10783 entity_id: self.state.entity_id,
10784 worker_instance_id: worker_instance_id.to_string(),
10785 item_instance_id,
10786 quantity,
10787 seq: self.seq,
10788 })
10789 .await?;
10790 self.state.intents_sent += 1;
10791 self.state
10792 .push_log(format!("Gave {item_label} to {worker_label}"));
10793 Ok(())
10794 }
10795
10796 pub async fn equip_item_on_worker(
10800 &mut self,
10801 worker_instance_id: &str,
10802 item_instance_id: uuid::Uuid,
10803 slot: &str,
10804 ) -> anyhow::Result<()> {
10805 let Some(worker) = self
10806 .state
10807 .hired_workers
10808 .iter()
10809 .find(|worker| worker.instance_id == worker_instance_id)
10810 .cloned()
10811 else {
10812 anyhow::bail!("worker not found");
10813 };
10814 let (px, py, _) = self.state.player_position_with_z();
10815 if (worker.x - px).hypot(worker.y - py) > WORKER_GIVE_RANGE_M {
10816 anyhow::bail!("worker {} too far — stand next to them", worker.label);
10817 }
10818 self.seq += 1;
10819 self.session
10820 .submit_intent(Intent::EquipWorkerItem {
10821 entity_id: self.state.entity_id,
10822 worker_instance_id: worker.instance_id.clone(),
10823 item_instance_id,
10824 slot: slot.to_string(),
10825 seq: self.seq,
10826 })
10827 .await?;
10828 self.state.intents_sent += 1;
10829 self.state
10830 .push_log(format!("Equipped {slot} on {}", worker.label));
10831 Ok(())
10832 }
10833
10834 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
10836 let Some(worker) = self
10837 .state
10838 .hired_workers
10839 .get(self.state.workers_menu_index)
10840 .cloned()
10841 else {
10842 anyhow::bail!("select a hired worker first");
10843 };
10844 let (px, py, _) = self.state.player_position_with_z();
10845 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10846 if dist > WORKER_GIVE_RANGE_M {
10847 anyhow::bail!(
10848 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
10849 worker.label
10850 );
10851 }
10852 let options = Self::worker_inventory_options(&worker);
10853 if options.is_empty() {
10854 anyhow::bail!("{} isn't carrying anything", worker.label);
10855 }
10856 let initial_qty = options
10857 .first()
10858 .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
10859 .unwrap_or(1);
10860 self.state.worker_take_picker = Some(WorkerTakePicker {
10861 worker_instance_id: worker.instance_id,
10862 worker_label: worker.label,
10863 options,
10864 quantity: initial_qty,
10865 });
10866 self.state.worker_take_picker_index = 0;
10867 self.state.show_worker_take_picker = true;
10868 Ok(())
10869 }
10870
10871 fn worker_inventory_options(
10872 worker: &flatland_protocol::HiredWorkerView,
10873 ) -> Vec<WorkerGiveOption> {
10874 worker
10875 .inventory
10876 .iter()
10877 .filter_map(|stack| {
10878 let item_instance_id = stack.item_instance_id?;
10879 let label = stack
10880 .display_name
10881 .clone()
10882 .unwrap_or_else(|| stack.template_id.clone());
10883 let label = if stack.quantity > 1 {
10884 format!("{label} ×{}", stack.quantity)
10885 } else {
10886 label
10887 };
10888 Some(WorkerGiveOption {
10889 item_instance_id,
10890 label,
10891 quantity: stack.quantity,
10892 template_id: stack.template_id.clone(),
10893 })
10894 })
10895 .collect()
10896 }
10897
10898 pub fn close_worker_take_picker(&mut self) {
10899 self.state.show_worker_take_picker = false;
10900 self.state.worker_take_picker = None;
10901 self.state.worker_take_picker_index = 0;
10902 }
10903
10904 pub fn worker_take_picker_move(&mut self, delta: i32) {
10905 let Some(picker) = &self.state.worker_take_picker else {
10906 return;
10907 };
10908 let n = picker.options.len();
10909 if n == 0 {
10910 return;
10911 }
10912 let idx = self.state.worker_take_picker_index as i32;
10913 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10914 self.clamp_worker_take_quantity();
10915 }
10916
10917 pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
10918 let Some(picker) = &mut self.state.worker_take_picker else {
10919 return;
10920 };
10921 let max = picker
10922 .options
10923 .get(self.state.worker_take_picker_index)
10924 .map(|o| o.quantity.max(1))
10925 .unwrap_or(1);
10926 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
10927 picker.quantity = next as u32;
10928 }
10929
10930 pub fn worker_take_picker_set_quantity_max(&mut self) {
10931 let Some(picker) = &mut self.state.worker_take_picker else {
10932 return;
10933 };
10934 let max = picker
10935 .options
10936 .get(self.state.worker_take_picker_index)
10937 .map(|o| o.quantity.max(1))
10938 .unwrap_or(1);
10939 picker.quantity = max;
10940 }
10941
10942 pub fn worker_take_picker_set_quantity_min(&mut self) {
10943 let Some(picker) = &mut self.state.worker_take_picker else {
10944 return;
10945 };
10946 picker.quantity = 1;
10947 self.clamp_worker_take_quantity();
10948 }
10949
10950 fn clamp_worker_take_quantity(&mut self) {
10951 let Some(picker) = &mut self.state.worker_take_picker else {
10952 return;
10953 };
10954 let max = picker
10955 .options
10956 .get(self.state.worker_take_picker_index)
10957 .map(|o| o.quantity.max(1))
10958 .unwrap_or(1);
10959 if picker.quantity == 0 || picker.quantity > max {
10960 picker.quantity = if max > 1 { 1 } else { max };
10961 }
10962 }
10963
10964 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
10965 let Some(picker) = self.state.worker_take_picker.clone() else {
10966 anyhow::bail!("take picker not open");
10967 };
10968 let Some(opt) = picker
10969 .options
10970 .get(self.state.worker_take_picker_index)
10971 .cloned()
10972 else {
10973 anyhow::bail!("no item selected");
10974 };
10975 let Some(worker) = self
10976 .state
10977 .hired_workers
10978 .iter()
10979 .find(|w| w.instance_id == picker.worker_instance_id)
10980 .cloned()
10981 else {
10982 self.close_worker_take_picker();
10983 anyhow::bail!("worker no longer hired");
10984 };
10985 let qty = picker.quantity.clamp(1, opt.quantity.max(1));
10986 let intent_qty = if qty >= opt.quantity { None } else { Some(qty) };
10987 self.take_item_from_worker(
10988 &worker.instance_id,
10989 &worker.label,
10990 worker.x,
10991 worker.y,
10992 opt.item_instance_id,
10993 &opt.label,
10994 intent_qty,
10995 )
10996 .await?;
10997 Ok(())
11000 }
11001
11002 async fn take_item_from_worker(
11003 &mut self,
11004 worker_instance_id: &str,
11005 worker_label: &str,
11006 worker_x: f32,
11007 worker_y: f32,
11008 item_instance_id: uuid::Uuid,
11009 item_label: &str,
11010 quantity: Option<u32>,
11011 ) -> anyhow::Result<()> {
11012 let (px, py, _) = self.state.player_position_with_z();
11013 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
11014 if dist > WORKER_GIVE_RANGE_M {
11015 anyhow::bail!("worker {worker_label} too far — stand next to them");
11016 }
11017 self.seq += 1;
11018 self.session
11019 .submit_intent(Intent::TakeWorkerItem {
11020 entity_id: self.state.entity_id,
11021 worker_instance_id: worker_instance_id.to_string(),
11022 item_instance_id,
11023 quantity,
11024 seq: self.seq,
11025 })
11026 .await?;
11027 self.state.intents_sent += 1;
11028 let qty_note = quantity.map(|q| format!(" ×{q}")).unwrap_or_default();
11029 self.state.push_log(format!(
11030 "Taking {item_label}{qty_note} from {worker_label}…"
11031 ));
11032 Ok(())
11033 }
11034
11035 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
11036 if !self.state.has_worker_lodging() {
11037 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
11038 }
11039 self.seq += 1;
11040 self.session
11041 .submit_intent(Intent::HireWorker {
11042 entity_id: self.state.entity_id,
11043 def_id: "worker_laborer".into(),
11044 wage_copper_per_interval: 8,
11045 lodging_container_id: None,
11046 job_yaml: None,
11047 seq: self.seq,
11048 })
11049 .await?;
11050 self.state.intents_sent += 1;
11051 Ok(())
11052 }
11053
11054 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
11055 let Some(worker) = self
11056 .state
11057 .hired_workers
11058 .get(self.state.workers_menu_index)
11059 .cloned()
11060 else {
11061 anyhow::bail!("select a hired worker first");
11062 };
11063 let lodging = worker.lodging_container_id.clone().or_else(|| {
11064 crate::worker_route_editor::owned_lodging_container_ids(
11065 &self.state.placed_containers,
11066 self.state.character_id,
11067 )
11068 .into_iter()
11069 .next()
11070 .map(|(id, _)| id)
11071 });
11072 let label = worker.label.clone();
11073 let editor = if let Some(route) = &worker.route {
11074 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
11075 worker.instance_id,
11076 worker.label,
11077 route,
11078 lodging,
11079 )
11080 } else {
11081 crate::worker_route_editor::WorkerRouteEditorState::new(
11082 worker.instance_id,
11083 worker.label,
11084 lodging,
11085 )
11086 };
11087 self.state.worker_route_editor = Some(editor);
11088 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11089 if let Some(collapsed) =
11090 crate::client_config::ClientConfig::load().worker_route_panel_collapsed
11091 {
11092 ed.panel_collapsed = collapsed;
11093 }
11094 }
11095 self.state.show_workers_menu = false;
11096 self.state.push_log(format!(
11097 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
11098 ));
11099 Ok(())
11100 }
11101
11102 pub fn close_worker_route_editor(&mut self) {
11103 self.state.worker_route_editor = None;
11104 }
11105
11106 pub fn worker_route_editor_toggle_panel(&mut self) {
11107 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11108 ed.toggle_panel_collapsed();
11109 let collapsed = ed.panel_collapsed;
11110 let mut cfg = crate::client_config::ClientConfig::load();
11111 let _ = cfg.save_worker_route_panel_collapsed(collapsed);
11112 }
11113 }
11114
11115 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
11116 let n = {
11117 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11118 return;
11119 };
11120 ed.append_waypoint(x, y, z);
11121 ed.stop_count()
11122 };
11123 self.state
11124 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
11125 }
11126
11127 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
11130 let (px, py, _) = self.state.player_position_with_z();
11131 let inside = self.state.effective_inside_building();
11132 crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
11133 &self.state.placed_containers,
11134 &self.state.buildings,
11135 self.state.character_id,
11136 px,
11137 py,
11138 &self.state.hired_workers,
11139 inside.as_deref(),
11140 )
11141 }
11142
11143 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
11144 self.state.route_editor_node_candidates()
11145 }
11146
11147 fn re_open_harvest_picker(&mut self, index: usize, picked: std::collections::BTreeSet<String>) {
11148 use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
11149 let nodes = self.state.route_editor_node_candidates();
11150 let index = if nodes.is_empty() {
11151 ROUTE_PICKER_DONE_ROW
11152 } else {
11153 index.max(1).min(nodes.len())
11154 };
11155 self.re_open_sheet(S::HarvestPicker {
11156 index,
11157 picked,
11158 nodes,
11159 });
11160 }
11161
11162 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
11163 let (px, py, _) = self.state.player_position_with_z();
11164 crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
11165 }
11166
11167 fn re_template_candidates(&self) -> Vec<String> {
11168 let mut extra = Vec::new();
11169 if let Some(ed) = self.state.worker_route_editor.as_ref() {
11170 for stop in &ed.stops {
11171 match stop {
11172 crate::worker_route_editor::WorkerRouteStop::DepositAt {
11173 filter: Some(filter),
11174 ..
11175 } => extra.extend(filter.iter().cloned()),
11176 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
11177 extra.push(template.clone());
11178 }
11179 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
11180 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint)
11181 {
11182 extra.push(bp.output.clone());
11183 for input in &bp.inputs {
11184 extra.push(input.template_id.clone());
11185 }
11186 }
11187 }
11188 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
11189 for it in items {
11190 extra.push(it.template.clone());
11191 }
11192 }
11193 _ => {}
11194 }
11195 }
11196 if let Some(worker) = self
11198 .state
11199 .hired_workers
11200 .iter()
11201 .find(|w| w.instance_id == ed.worker_instance_id)
11202 {
11203 for recipe in &worker.known_blueprint_ids {
11204 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
11205 extra.push(bp.output.clone());
11206 }
11207 }
11208 }
11209 }
11210 crate::worker_route_editor::route_item_template_candidates(
11211 &self.state.placed_containers,
11212 self.state.character_id,
11213 &self.state.inventory,
11214 &self.state.blueprints,
11215 &self.state.resource_nodes,
11216 &extra,
11217 )
11218 }
11219
11220 fn re_blueprint_ids(&self) -> Vec<String> {
11221 let worker_known: Option<&[String]> = self
11222 .state
11223 .worker_route_editor
11224 .as_ref()
11225 .and_then(|ed| {
11226 self.state
11227 .hired_workers
11228 .iter()
11229 .find(|w| w.instance_id == ed.worker_instance_id)
11230 })
11231 .map(|w| w.known_blueprint_ids.as_slice());
11232 crate::worker_route_editor::worker_craft_blueprint_ids(&self.state.blueprints, worker_known)
11233 }
11234
11235 fn re_bed_candidates(&self) -> Vec<(String, String)> {
11236 crate::worker_route_editor::owned_lodging_container_ids(
11237 &self.state.placed_containers,
11238 self.state.character_id,
11239 )
11240 }
11241
11242 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
11243 self.state
11244 .placed_containers
11245 .iter()
11246 .find(|c| c.id == container_id)
11247 .map(|c| c.contents.clone())
11248 .unwrap_or_default()
11249 }
11250
11251 fn re_sheet_supports_filter(&self) -> bool {
11254 use crate::worker_route_editor::RouteEditorSheet as S;
11255 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
11256 matches!(
11257 ed.sheet,
11258 S::HarvestPicker { .. }
11259 | S::SellItem { .. }
11260 | S::DepositFilter { .. }
11261 | S::WithdrawItems { .. }
11262 | S::WithdrawContainers { .. }
11263 | S::DepositContainers { .. }
11264 | S::SellNpcs { .. }
11265 | S::CraftBlueprint { .. }
11266 | S::BedPicker { .. }
11267 )
11268 })
11269 }
11270
11271 pub fn re_sheet_row_visible(&self, row: usize) -> bool {
11273 use crate::worker_route_editor::{
11274 harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
11275 ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
11276 };
11277 let Some(ed) = self.state.worker_route_editor.as_ref() else {
11278 return false;
11279 };
11280 let filter = &ed.sheet_filter;
11281 match &ed.sheet {
11282 S::HarvestPicker { nodes, .. } => harvest_picker_row_matches(nodes, row, filter),
11283 S::SellItem { templates, .. } => {
11284 if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
11285 return true;
11286 }
11287 let slot = row.saturating_sub(2);
11288 templates.get(slot).is_some_and(|t| {
11289 let label = self.state.template_display_name(t);
11290 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
11291 })
11292 }
11293 S::DepositFilter { rows, .. } => {
11294 if row >= rows.len() {
11295 return true;
11296 }
11297 rows.get(row).is_some_and(|(t, _)| {
11298 let label = self.state.template_display_name(t);
11299 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
11300 })
11301 }
11302 S::WithdrawItems { lines, .. } => {
11303 if row >= lines.len() {
11304 return true;
11305 }
11306 lines.get(row).is_some_and(|l| {
11307 let label = self.state.template_display_name(&l.template);
11308 list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
11309 })
11310 }
11311 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
11312 self.re_container_candidates().get(row).is_some_and(|c| {
11313 list_filter_row_matches(
11314 filter,
11315 Some(c.dist),
11316 &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
11317 )
11318 })
11319 }
11320 S::SellNpcs { .. } => {
11321 if row == 0 {
11322 return true;
11323 }
11324 self.re_npc_candidates().get(row - 1).is_some_and(|n| {
11325 list_filter_row_matches(
11326 filter,
11327 Some(n.dist),
11328 &[n.label.as_str(), n.id.as_str()],
11329 )
11330 })
11331 }
11332 S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
11333 let label = self
11334 .state
11335 .blueprints
11336 .iter()
11337 .find(|b| &b.id == id)
11338 .map(|b| {
11339 if b.label.is_empty() {
11340 id.as_str()
11341 } else {
11342 b.label.as_str()
11343 }
11344 })
11345 .unwrap_or(id.as_str());
11346 list_filter_row_matches(filter, None, &[id.as_str(), label])
11347 }),
11348 S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
11349 list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
11350 }),
11351 _ => true,
11352 }
11353 }
11354
11355 fn re_sheet_clamp_index(&mut self) {
11356 let count = self.re_sheet_row_count();
11357 if count == 0 {
11358 return;
11359 }
11360 let cur = self.re_sheet_index();
11361 if self.re_sheet_row_visible(cur) {
11362 return;
11363 }
11364 for offset in 1..count {
11365 if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
11366 self.re_sheet_set_index(cur + offset);
11367 return;
11368 }
11369 if cur >= offset && self.re_sheet_row_visible(cur - offset) {
11370 self.re_sheet_set_index(cur - offset);
11371 return;
11372 }
11373 }
11374 }
11375
11376 fn re_sheet_set_index(&mut self, index: usize) {
11377 use crate::worker_route_editor::RouteEditorSheet as S;
11378 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11379 return;
11380 };
11381 match &mut ed.sheet {
11382 S::AddMenu { index: slot }
11383 | S::WaypointMenu { index: slot }
11384 | S::HarvestPicker { index: slot, .. }
11385 | S::WithdrawContainers { index: slot }
11386 | S::DepositContainers { index: slot }
11387 | S::SellNpcs { index: slot }
11388 | S::CraftBlueprint { index: slot }
11389 | S::BedPicker { index: slot }
11390 | S::FarmPlotPicker { index: slot, .. }
11391 | S::FarmPlantSeed { index: slot, .. }
11392 | S::WithdrawItems { index: slot, .. }
11393 | S::DepositFilter { index: slot, .. }
11394 | S::SellItem { index: slot, .. } => *slot = index,
11395 _ => {}
11396 }
11397 }
11398
11399 pub fn re_focus_sheet_filter(&mut self) {
11400 if !self.re_sheet_supports_filter() {
11401 return;
11402 }
11403 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11404 ed.sheet_filter_focused = true;
11405 }
11406 }
11407
11408 pub fn re_blur_sheet_filter_keep_text(&mut self) {
11409 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11410 return;
11411 };
11412 if !ed.sheet_filter_focused {
11413 return;
11414 }
11415 ed.sheet_filter_focused = false;
11416 self.re_sheet_clamp_index();
11417 }
11418
11419 pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
11420 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11421 return false;
11422 };
11423 if ed.sheet_filter_focused {
11424 ed.sheet_filter_focused = false;
11425 self.re_sheet_clamp_index();
11426 return true;
11427 }
11428 if !ed.sheet_filter.is_empty() {
11429 ed.sheet_filter.clear();
11430 self.re_sheet_clamp_index();
11431 return true;
11432 }
11433 false
11434 }
11435
11436 pub fn re_append_sheet_filter_char(&mut self, ch: char) {
11437 if ch.is_control() {
11438 return;
11439 }
11440 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11441 return;
11442 };
11443 if !ed.sheet_filter_focused {
11444 return;
11445 }
11446 ed.sheet_filter.push(ch);
11447 self.re_sheet_set_index(0);
11448 self.re_sheet_clamp_index();
11449 }
11450
11451 pub fn re_sheet_filter_backspace(&mut self) {
11452 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11453 return;
11454 };
11455 if !ed.sheet_filter_focused {
11456 return;
11457 }
11458 ed.sheet_filter.pop();
11459 self.re_sheet_set_index(0);
11460 self.re_sheet_clamp_index();
11461 }
11462
11463 pub fn re_sheet_row_count(&self) -> usize {
11465 use crate::worker_route_editor::{
11466 harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
11467 };
11468 let Some(ed) = self.state.worker_route_editor.as_ref() else {
11469 return 0;
11470 };
11471 match &ed.sheet {
11472 S::Stops => ed.stops.len(),
11473 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
11474 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
11475 S::WaypointMapPick => 0,
11476 S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
11477 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
11478 self.re_container_candidates().len()
11479 }
11480 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()),
11484 S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
11485 S::WaitEntry { .. } => 1,
11486 S::BedPicker { .. } => self.re_bed_candidates().len(),
11487 S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
11488 S::FarmPlantSeed { seeds, .. } => seeds.len(),
11489 }
11490 }
11491
11492 pub fn re_sheet_index(&self) -> usize {
11494 use crate::worker_route_editor::RouteEditorSheet as S;
11495 let Some(ed) = self.state.worker_route_editor.as_ref() else {
11496 return 0;
11497 };
11498 match &ed.sheet {
11499 S::AddMenu { index }
11500 | S::WaypointMenu { index }
11501 | S::HarvestPicker { index, .. }
11502 | S::WithdrawContainers { index }
11503 | S::DepositContainers { index }
11504 | S::SellNpcs { index }
11505 | S::CraftBlueprint { index }
11506 | S::BedPicker { index }
11507 | S::FarmPlotPicker { index, .. }
11508 | S::FarmPlantSeed { index, .. }
11509 | S::WithdrawItems { index, .. }
11510 | S::DepositFilter { index, .. }
11511 | S::SellItem { index, .. } => *index,
11512 _ => 0,
11513 }
11514 }
11515
11516 pub fn re_sheet_move(&mut self, delta: i32) {
11518 let count = self.re_sheet_row_count();
11519 if count == 0 {
11520 return;
11521 }
11522 let cur = self.re_sheet_index();
11523 let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
11524 self.re_sheet_set_index(next);
11525 }
11526
11527 pub fn re_sheet_page(&mut self, pages: i32) {
11528 let count = self.re_sheet_row_count();
11529 if count == 0 {
11530 return;
11531 }
11532 let cur = self.re_sheet_index();
11533 let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
11534 self.re_sheet_set_index(next);
11535 }
11536
11537 pub fn re_sheet_adjust(&mut self, delta: i32) {
11539 use crate::worker_route_editor::RouteEditorSheet as S;
11540 let index = self.re_sheet_index();
11541 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11542 return;
11543 };
11544 match &mut ed.sheet {
11545 S::WithdrawItems { lines, .. } => {
11546 if let Some(line) = lines.get_mut(index) {
11547 line.adjust_qty(delta);
11548 }
11549 }
11550 S::WaitEntry { ticks } => {
11551 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
11552 }
11553 _ => {}
11554 }
11555 }
11556
11557 pub fn re_sheet_back(&mut self) {
11558 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11559 return;
11560 };
11561 use crate::worker_route_editor::RouteEditorSheet as S;
11562 let was_editing = ed.editing_index.is_some();
11563 let from_top_picker = matches!(
11564 ed.sheet,
11565 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
11566 );
11567 ed.sheet_back();
11568 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
11569 self.state
11571 .push_log("Route: left edit sheet — press s to save current stops".to_string());
11572 }
11573 }
11574
11575 pub fn re_at_root_sheet(&self) -> bool {
11577 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
11578 matches!(
11579 ed.sheet,
11580 crate::worker_route_editor::RouteEditorSheet::Stops
11581 )
11582 })
11583 }
11584
11585 pub fn re_open_add_menu(&mut self) {
11586 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11587 ed.open_add_menu();
11588 }
11589 }
11590
11591 pub fn re_open_bed_picker(&mut self) {
11592 let beds = self.re_bed_candidates();
11593 if beds.is_empty() {
11594 self.state
11595 .push_log("Route: place a camp bed first".to_string());
11596 return;
11597 }
11598 let current = self
11599 .state
11600 .worker_route_editor
11601 .as_ref()
11602 .and_then(|ed| ed.lodging_container_id.clone());
11603 let index = current
11604 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
11605 .unwrap_or(0);
11606 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
11607 }
11608
11609 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
11610 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11611 ed.open_sheet(sheet);
11612 }
11613 }
11614
11615 fn re_confirm_stop(&mut self, stop: crate::worker_route_editor::WorkerRouteStop, what: String) {
11617 let appended = self
11618 .state
11619 .worker_route_editor
11620 .as_mut()
11621 .is_some_and(|ed| ed.confirm_stop(stop));
11622 if appended {
11623 self.state.push_log(format!("Route: + {what}"));
11624 } else {
11625 self.state
11626 .push_log(format!("Route: {what} already in route — selected it"));
11627 }
11628 }
11629
11630 fn re_open_withdraw_items(&mut self, container_id: String) {
11631 use crate::worker_route_editor::{
11632 RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
11633 };
11634 let contents = self.re_container_contents(&container_id);
11635 let existing = self
11639 .state
11640 .worker_route_editor
11641 .as_ref()
11642 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11643 .and_then(|stop| match stop {
11644 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
11645 _ => None,
11646 })
11647 .unwrap_or_default();
11648 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
11649 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11652 let _ = ed.retarget_withdraw_container(container_id.clone());
11653 }
11654 self.re_open_sheet(S::WithdrawItems {
11655 container_id,
11656 lines,
11657 index: 0,
11658 });
11659 }
11660
11661 fn re_withdraw_items_activate(&mut self, index: usize) {
11662 use crate::worker_route_editor::{
11663 RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
11664 };
11665 enum Outcome {
11666 Cycled,
11667 Confirmed(String),
11668 Empty,
11669 }
11670 let outcome = {
11671 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11672 return;
11673 };
11674 let S::WithdrawItems {
11675 container_id,
11676 lines,
11677 index: sheet_index,
11678 } = &mut ed.sheet
11679 else {
11680 return;
11681 };
11682 *sheet_index = index;
11683 if index < lines.len() {
11684 lines[index].cycle();
11685 Outcome::Cycled
11686 } else {
11687 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
11688 if items.is_empty() {
11689 Outcome::Empty
11690 } else {
11691 let stop = WorkerRouteStop::WithdrawFrom {
11692 container_id: container_id.clone(),
11693 items,
11694 };
11695 let summary = stop.summary();
11696 ed.confirm_stop(stop);
11697 Outcome::Confirmed(summary)
11698 }
11699 }
11700 };
11701 match outcome {
11702 Outcome::Cycled => {}
11703 Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
11704 Outcome::Empty => self.state.push_log(
11705 "Route: pick at least one item (Space/Enter toggles All/qty)".to_string(),
11706 ),
11707 }
11708 }
11709
11710 fn re_open_deposit_filter(&mut self, container_id: String) {
11711 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11712 let existing_filter = self
11714 .state
11715 .worker_route_editor
11716 .as_ref()
11717 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11718 .and_then(|stop| match stop {
11719 WorkerRouteStop::DepositAt { filter, .. } => {
11720 Some(filter.clone().unwrap_or_default())
11721 }
11722 _ => None,
11723 });
11724 let mut candidates = self.re_template_candidates();
11725 if let Some(ref chosen) = existing_filter {
11726 for t in chosen {
11727 if !candidates.iter().any(|c| c == t) {
11728 candidates.push(t.clone());
11729 }
11730 }
11731 candidates.sort();
11732 candidates.dedup();
11733 }
11734 let rows: Vec<(String, bool)> = match existing_filter {
11735 Some(chosen) => candidates
11736 .iter()
11737 .map(|t| (t.clone(), chosen.contains(t)))
11738 .collect(),
11739 None => candidates.into_iter().map(|t| (t, false)).collect(),
11740 };
11741 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11742 let _ = ed.retarget_deposit_container(container_id.clone());
11743 }
11744 self.re_open_sheet(S::DepositFilter {
11745 container_id,
11746 rows,
11747 index: 0,
11748 });
11749 }
11750
11751 fn re_deposit_filter_activate(&mut self, index: usize) {
11752 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11753 let mut confirmed: Option<String> = None;
11754 {
11755 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11756 return;
11757 };
11758 let S::DepositFilter {
11759 container_id,
11760 rows,
11761 index: sheet_index,
11762 } = &mut ed.sheet
11763 else {
11764 return;
11765 };
11766 *sheet_index = index;
11767 if index < rows.len() {
11768 rows[index].1 = !rows[index].1;
11769 } else {
11770 let chosen: Vec<String> = rows
11772 .iter()
11773 .filter(|(_, on)| *on)
11774 .map(|(t, _)| t.clone())
11775 .collect();
11776 let filter = if chosen.is_empty() {
11777 None
11778 } else {
11779 Some(chosen)
11780 };
11781 let stop = WorkerRouteStop::DepositAt {
11782 container_id: container_id.clone(),
11783 filter,
11784 };
11785 confirmed = Some(stop.summary());
11786 ed.confirm_stop(stop);
11787 }
11788 }
11789 if let Some(what) = confirmed {
11790 self.state.push_log(format!("Route: + {what}"));
11791 }
11792 }
11793
11794 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
11795 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11796 let (pre_npc, pre_template, pre_all) = self
11798 .state
11799 .worker_route_editor
11800 .as_ref()
11801 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11802 .and_then(|stop| match stop {
11803 WorkerRouteStop::TradeWith {
11804 npc_id,
11805 template,
11806 sell_all,
11807 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
11808 _ => None,
11809 })
11810 .unwrap_or((None, None, true));
11811 let npc_id = npc_id.or(pre_npc);
11812 let mut templates = crate::worker_route_editor::sellable_route_item_template_candidates(
11813 &self.re_template_candidates(),
11814 &self.state.npcs,
11815 npc_id.as_deref(),
11816 );
11817 if let Some(template) = pre_template.as_ref() {
11820 if !templates.iter().any(|candidate| candidate == template) {
11821 templates.push(template.clone());
11822 templates.sort();
11823 }
11824 }
11825 if templates.is_empty() {
11826 self.state.push_log(
11827 "Route: no sellable item templates for that merchant".to_string(),
11828 );
11829 return;
11830 }
11831 let mut picked = std::collections::BTreeSet::new();
11832 if let Some(t) = pre_template {
11833 picked.insert(t);
11834 }
11835 self.re_open_sheet(S::SellItem {
11836 npc_id,
11837 templates,
11838 index: if picked.is_empty() {
11839 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
11840 } else {
11841 2
11842 },
11843 sell_all: pre_all,
11844 picked,
11845 });
11846 }
11847
11848 fn re_sell_item_activate(&mut self, index: usize) {
11849 use crate::worker_route_editor::{
11850 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
11851 };
11852 let mut batch_log: Option<String> = None;
11853 {
11854 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11855 return;
11856 };
11857 let S::SellItem {
11858 npc_id,
11859 templates,
11860 index: sheet_index,
11861 sell_all,
11862 picked,
11863 } = &mut ed.sheet
11864 else {
11865 return;
11866 };
11867 *sheet_index = index;
11868 if index == ROUTE_PICKER_DONE_ROW {
11869 if picked.is_empty() {
11870 batch_log =
11871 Some("Route: pick at least one item (Space toggles, Done confirms)".into());
11872 } else {
11873 let picks: Vec<String> = picked.iter().cloned().collect();
11874 let npc = npc_id.clone();
11875 let all = *sell_all;
11876 let added = ed.confirm_trade_picks(npc, &picks, all);
11877 batch_log = Some(format!("Route: + {added} sell stop(s)"));
11878 }
11879 } else if index == SELL_ITEM_TOGGLE_ROW {
11880 *sell_all = !*sell_all;
11881 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
11882 let sellable = crate::worker_route_editor::sellable_route_item_template_candidates(
11883 std::slice::from_ref(template),
11884 &self.state.npcs,
11885 npc_id.as_deref(),
11886 )
11887 .iter()
11888 .any(|candidate| candidate == template);
11889 if !sellable && !picked.contains(template) {
11890 return;
11891 }
11892 if picked.contains(template) {
11893 picked.remove(template);
11894 } else {
11895 picked.insert(template.clone());
11896 }
11897 }
11898 }
11899 if let Some(msg) = batch_log {
11900 self.state.push_log(msg);
11901 }
11902 }
11903
11904 pub fn re_edit_selected_stop(&mut self) {
11906 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11907 let Some(stop) = self
11908 .state
11909 .worker_route_editor
11910 .as_ref()
11911 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
11912 else {
11913 self.state
11914 .push_log("Route: no stop selected — press a to add one".to_string());
11915 return;
11916 };
11917 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11918 ed.begin_edit_selected();
11919 }
11920 match stop {
11921 WorkerRouteStop::Waypoint { .. } => {
11922 self.re_open_sheet(S::WaypointMenu { index: 0 });
11923 }
11924 WorkerRouteStop::HarvestNode { node_id } => {
11925 let nodes = self.state.route_editor_node_candidates();
11926 if nodes.is_empty() {
11927 self.re_cancel_edit();
11928 self.state
11929 .push_log("Route: no harvestable nodes visible to retarget".to_string());
11930 } else {
11931 let mut picked = std::collections::BTreeSet::new();
11932 picked.insert(node_id.clone());
11933 let index = nodes
11934 .iter()
11935 .position(|n| n.id == node_id)
11936 .map(|i| i + 1)
11937 .unwrap_or(1);
11938 self.re_open_harvest_picker(index, picked);
11939 }
11940 }
11941 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
11942 let containers = self.re_container_candidates();
11945 if containers.is_empty() {
11946 self.re_cancel_edit();
11947 self.state
11948 .push_log("Route: place a storage chest first".to_string());
11949 } else {
11950 let index = containers
11951 .iter()
11952 .position(|c| c.id == container_id)
11953 .unwrap_or(0);
11954 self.re_open_sheet(S::WithdrawContainers { index });
11955 }
11956 }
11957 WorkerRouteStop::DepositAt { container_id, .. } => {
11958 let containers = self.re_container_candidates();
11959 if containers.is_empty() {
11960 self.re_cancel_edit();
11961 self.state
11962 .push_log("Route: place a storage chest first".to_string());
11963 } else {
11964 let index = containers
11965 .iter()
11966 .position(|c| c.id == container_id)
11967 .unwrap_or(0);
11968 self.re_open_sheet(S::DepositContainers { index });
11969 }
11970 }
11971 WorkerRouteStop::TradeWith { npc_id, .. } => {
11972 let npcs = self.re_npc_candidates();
11973 let index = npc_id
11975 .as_ref()
11976 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
11977 .unwrap_or(0);
11978 self.re_open_sheet(S::SellNpcs { index });
11979 }
11980 WorkerRouteStop::CraftAt { blueprint, .. } => {
11981 let bps = self.re_blueprint_ids();
11982 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
11983 if bps.is_empty() {
11984 self.re_cancel_edit();
11985 self.state
11986 .push_log("Route: no known blueprints to retarget".to_string());
11987 } else {
11988 self.re_open_sheet(S::CraftBlueprint { index });
11989 }
11990 }
11991 WorkerRouteStop::CultivatePlot { .. } => {
11992 self.re_open_farm_plot_picker(
11993 crate::worker_route_editor::FarmPlotAction::Cultivate,
11994 );
11995 }
11996 WorkerRouteStop::PlantPlot { .. } => {
11997 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
11998 }
11999 WorkerRouteStop::HarvestPlot { .. } => {
12000 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
12001 }
12002 WorkerRouteStop::RestIfNeeded => {
12003 self.re_cancel_edit();
12004 self.state
12005 .push_log("Route: rest has no settings (change the bed with l)".to_string());
12006 }
12007 WorkerRouteStop::Wait { wait_ticks } => {
12008 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
12009 }
12010 }
12011 }
12012
12013 fn re_cancel_edit(&mut self) {
12014 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12015 ed.editing_index = None;
12016 }
12017 }
12018
12019 pub fn worker_route_editor_ui_click(
12022 &mut self,
12023 click: crate::worker_route_editor::RouteEditorClick,
12024 ) {
12025 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
12026 match click {
12027 RouteEditorClick::SelectStop(i) => {
12028 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12029 ed.sheet = S::Stops;
12030 ed.select_stop(i);
12031 }
12032 }
12033 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
12034 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
12035 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
12036 }
12037 }
12038
12039 pub fn re_sheet_row_activate(&mut self, row: usize) {
12041 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12042 let Some(sheet) = self
12043 .state
12044 .worker_route_editor
12045 .as_ref()
12046 .map(|ed| ed.sheet.clone())
12047 else {
12048 return;
12049 };
12050 match sheet {
12051 S::Stops => {
12052 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12053 ed.select_stop(row);
12054 }
12055 }
12056 S::AddMenu { .. } => match row {
12057 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
12058 1 => {
12059 if self.re_node_candidates().is_empty() {
12060 self.state.push_log(
12061 "Route: no harvestable nodes visible in this region".to_string(),
12062 );
12063 } else {
12064 self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
12065 }
12066 }
12067 2 | 3 => {
12068 if self.re_container_candidates().is_empty() {
12069 self.state
12070 .push_log("Route: place a storage chest first".to_string());
12071 } else if row == 2 {
12072 self.re_open_sheet(S::WithdrawContainers { index: 0 });
12073 } else {
12074 self.re_open_sheet(S::DepositContainers { index: 0 });
12075 }
12076 }
12077 4 => {
12078 if self.re_template_candidates().is_empty() {
12079 self.state.push_log(
12080 "Route: no item templates available — learn a craft recipe or place a harvest node first"
12081 .to_string(),
12082 );
12083 } else {
12084 self.re_open_sheet(S::SellNpcs { index: 0 });
12085 }
12086 }
12087 5 => {
12088 if self.re_blueprint_ids().is_empty() {
12089 self.state.push_log(
12090 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
12091 .to_string(),
12092 );
12093 } else {
12094 self.re_open_sheet(S::CraftBlueprint { index: 0 });
12095 }
12096 }
12097 6 => self.re_confirm_stop(
12098 WorkerRouteStop::RestIfNeeded,
12099 "rest at lodging (if needed)".into(),
12100 ),
12101 7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
12102 8 => self.re_open_farm_plot_picker(
12103 crate::worker_route_editor::FarmPlotAction::Cultivate,
12104 ),
12105 9 => {
12106 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant)
12107 }
12108 10 => self
12109 .re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
12110 _ => {}
12111 },
12112 S::WaypointMenu { .. } => match row {
12113 0 => {
12114 let (x, y, z) = self.state.player_position_with_z();
12115 let stop = WorkerRouteStop::Waypoint { x, y, z };
12116 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
12117 }
12118 1 => {
12119 self.re_open_sheet(S::WaypointMapPick);
12120 self.state.push_log(
12121 "Route: click the map to place the waypoint (Esc to finish)".to_string(),
12122 );
12123 }
12124 _ => {}
12125 },
12126 S::HarvestPicker { .. } => {
12127 let mut log: Option<String> = None;
12128 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12129 let S::HarvestPicker {
12130 index: sheet_index,
12131 picked,
12132 nodes,
12133 } = &mut ed.sheet
12134 else {
12135 return;
12136 };
12137 *sheet_index = row;
12138 if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
12139 if picked.is_empty() {
12140 log = Some(
12141 "Route: pick at least one node (Space toggles, Done confirms)"
12142 .into(),
12143 );
12144 } else {
12145 let ids: Vec<String> = picked.iter().cloned().collect();
12146 let added = ed.confirm_harvest_picks(&ids);
12147 log = Some(format!("Route: + {added} harvest stop(s)"));
12148 }
12149 } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
12150 if picked.contains(&n.id) {
12151 picked.remove(&n.id);
12152 } else {
12153 picked.insert(n.id.clone());
12154 }
12155 }
12156 }
12157 if let Some(msg) = log {
12158 self.state.push_log(msg);
12159 }
12160 }
12161 S::WithdrawContainers { .. } => {
12162 let containers = self.re_container_candidates();
12163 if let Some(c) = containers.get(row) {
12164 let id = c.id.clone();
12165 self.re_open_withdraw_items(id);
12166 }
12167 }
12168 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
12169 S::DepositContainers { .. } => {
12170 let containers = self.re_container_candidates();
12171 if let Some(c) = containers.get(row) {
12172 let id = c.id.clone();
12173 self.re_open_deposit_filter(id);
12174 }
12175 }
12176 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
12177 S::SellNpcs { .. } => {
12178 let npcs = self.re_npc_candidates();
12179 let npc_id = if row == 0 {
12180 None
12181 } else {
12182 npcs.get(row - 1).map(|n| n.id.clone())
12183 };
12184 if row == 0 || npc_id.is_some() {
12185 self.re_open_sell_item(npc_id);
12186 }
12187 }
12188 S::SellItem { .. } => self.re_sell_item_activate(row),
12189 S::CraftBlueprint { .. } => {
12190 let bps = self.re_blueprint_ids();
12191 if let Some(bp) = bps.get(row) {
12192 let stop = WorkerRouteStop::CraftAt {
12193 device: "hand".into(),
12194 blueprint: bp.clone(),
12195 qty: None,
12196 };
12197 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
12198 }
12199 }
12200 S::WaitEntry { ticks } => {
12201 let stop = WorkerRouteStop::Wait { wait_ticks: ticks };
12202 self.re_confirm_stop(stop, format!("wait {ticks}t"));
12203 }
12204 S::BedPicker { .. } => {
12205 let beds = self.re_bed_candidates();
12206 if let Some((id, name)) = beds.get(row) {
12207 let (id, name) = (id.clone(), name.clone());
12208 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12209 ed.lodging_container_id = Some(id.clone());
12210 ed.sheet = S::Stops;
12211 }
12212 self.state
12213 .push_log(format!("Route: rest bed set to {name}"));
12214 }
12215 }
12216 S::FarmPlotPicker { action, .. } => {
12217 let plots = self.re_farm_plot_candidates();
12218 let Some(plot) = plots.get(row).cloned() else {
12219 return;
12220 };
12221 match action {
12222 crate::worker_route_editor::FarmPlotAction::Cultivate => {
12223 let label = plot_route_label(&plot);
12224 self.re_confirm_stop(
12225 WorkerRouteStop::CultivatePlot {
12226 plot_id: plot.plot_id,
12227 },
12228 format!("cultivate {label}"),
12229 );
12230 }
12231 crate::worker_route_editor::FarmPlotAction::Harvest => {
12232 let label = plot_route_label(&plot);
12233 self.re_confirm_stop(
12234 WorkerRouteStop::HarvestPlot {
12235 plot_id: plot.plot_id,
12236 },
12237 format!("harvest {label}"),
12238 );
12239 }
12240 crate::worker_route_editor::FarmPlotAction::Plant => {
12241 let seeds = self.re_farm_seed_candidates();
12242 if seeds.is_empty() {
12243 self.state.push_log(
12244 "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
12245 );
12246 return;
12247 }
12248 self.re_open_sheet(S::FarmPlantSeed {
12249 plot_id: plot.plot_id,
12250 seeds,
12251 index: 0,
12252 });
12253 }
12254 }
12255 }
12256 S::FarmPlantSeed { plot_id, seeds, .. } => {
12257 if let Some(seed) = seeds.get(row).cloned() {
12258 self.re_confirm_stop(
12259 WorkerRouteStop::PlantPlot {
12260 plot_id,
12261 seed_template: seed.clone(),
12262 },
12263 format!("plant {seed}"),
12264 );
12265 }
12266 }
12267 S::WaypointMapPick => {}
12268 }
12269 }
12270
12271 fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
12272 use crate::worker_route_editor::RouteEditorSheet as S;
12273 if self.re_farm_plot_candidates().is_empty() {
12274 self.state
12275 .push_log("Route: no farmable plots visible — claim land or get farm access first");
12276 return;
12277 }
12278 self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
12279 }
12280
12281 fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
12282 self.state
12283 .property_plots
12284 .iter()
12285 .filter(|p| p.is_mine || p.may_farm)
12286 .cloned()
12287 .collect()
12288 }
12289
12290 fn re_farm_seed_candidates(&self) -> Vec<String> {
12294 let mut set = std::collections::BTreeSet::new();
12295 let looks_like_seed =
12296 |id: &str| id.ends_with("_seed") || id == "potato_seed" || id == "carrot_seed";
12297 for (id, _, _) in self.state.farm_seed_entries() {
12298 set.insert(id);
12299 }
12300 for c in &self.state.placed_containers {
12301 let mine = match (self.state.character_id, c.owner_character_id) {
12302 (Some(a), Some(b)) => a == b,
12303 _ => false,
12304 };
12305 if !mine {
12306 continue;
12307 }
12308 for s in &c.contents {
12309 if s.quantity > 0
12310 && (s.props.contains_key("seed_for") || looks_like_seed(&s.template_id))
12311 {
12312 set.insert(s.template_id.clone());
12313 }
12314 }
12315 }
12316 if let Some(ed) = self.state.worker_route_editor.as_ref() {
12317 for stop in &ed.stops {
12318 if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } =
12319 stop
12320 {
12321 for it in items {
12322 if looks_like_seed(&it.template) {
12323 set.insert(it.template.clone());
12324 }
12325 }
12326 }
12327 if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
12328 seed_template,
12329 ..
12330 } = stop
12331 {
12332 if !seed_template.is_empty() {
12333 set.insert(seed_template.clone());
12334 }
12335 }
12336 }
12337 }
12338 for id in self.state.inventory_hints.keys() {
12339 if looks_like_seed(id) {
12340 set.insert(id.clone());
12341 }
12342 }
12343 for id in ["potato_seed", "carrot_seed"] {
12345 set.insert(id.to_string());
12346 }
12347 set.into_iter().collect()
12348 }
12349
12350 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
12357 use crate::worker_route_editor as wre;
12358 use wre::RouteEditorSheet as S;
12359 if self.state.worker_route_editor.is_none() {
12360 return;
12361 }
12362 let sheet = self
12363 .state
12364 .worker_route_editor
12365 .as_ref()
12366 .map(|ed| ed.sheet.clone())
12367 .unwrap_or(S::Stops);
12368 match sheet {
12369 S::WaypointMapPick => {
12370 let (_, _, z) = self.state.player_position_with_z();
12371 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
12372 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
12373 let editing = self
12375 .state
12376 .worker_route_editor
12377 .as_ref()
12378 .is_some_and(|ed| ed.editing_index.is_some());
12379 if !editing {
12380 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12381 ed.sheet = S::WaypointMapPick;
12382 }
12383 }
12384 }
12385 S::HarvestPicker { .. } => {
12386 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
12387 let mut log: Option<String> = None;
12388 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12389 let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
12390 return;
12391 };
12392 let selected = if picked.contains(&node.id) {
12393 picked.remove(&node.id);
12394 false
12395 } else {
12396 picked.insert(node.id.clone());
12397 true
12398 };
12399 log = Some(format!(
12400 "Route: {} {}",
12401 if selected { "selected" } else { "deselected" },
12402 resource_node_route_label(node)
12403 ));
12404 }
12405 if let Some(msg) = log {
12406 self.state.push_log(msg);
12407 }
12408 }
12409 }
12410 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
12411 let inside = self.state.effective_inside_building();
12413 if let Some(cid) = wre::pick_storage_container_at(
12414 &self.state.placed_containers,
12415 self.state.character_id,
12416 x,
12417 y,
12418 inside.as_deref(),
12419 ) {
12420 self.re_open_withdraw_items(cid);
12421 }
12422 }
12423 S::DepositContainers { .. } | S::DepositFilter { .. } => {
12424 let inside = self.state.effective_inside_building();
12425 if let Some(cid) = wre::pick_storage_container_at(
12426 &self.state.placed_containers,
12427 self.state.character_id,
12428 x,
12429 y,
12430 inside.as_deref(),
12431 ) {
12432 self.re_open_deposit_filter(cid);
12433 }
12434 }
12435 S::SellNpcs { .. } => {
12436 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12437 self.re_open_sell_item(Some(npc_id));
12438 }
12439 }
12440 S::SellItem { .. } => {
12441 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12442 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12443 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
12444 *slot = Some(npc_id.clone());
12445 }
12446 }
12447 self.state
12448 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
12449 }
12450 }
12451 _ => self.worker_route_editor_quick_add_click(x, y),
12453 }
12454 }
12455
12456 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
12460 use crate::worker_route_editor as wre;
12461 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
12462 let dx = ax - bx;
12463 let dy = ay - by;
12464 (dx * dx + dy * dy).sqrt()
12465 };
12466
12467 let selected_stop_kind = self
12470 .state
12471 .worker_route_editor
12472 .as_ref()
12473 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
12474 .map(|s| match s {
12475 wre::WorkerRouteStop::TradeWith { .. } => 1,
12476 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
12477 _ => 0,
12478 })
12479 .unwrap_or(0);
12480 if selected_stop_kind == 1 {
12481 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12482 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12483 ed.set_selected_trade_npc(npc_id.clone());
12484 }
12485 self.state
12486 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
12487 return;
12488 }
12489 }
12490 if selected_stop_kind == 2 {
12491 let inside = self.state.effective_inside_building();
12492 if let Some(cid) = wre::pick_storage_container_at(
12493 &self.state.placed_containers,
12494 self.state.character_id,
12495 x,
12496 y,
12497 inside.as_deref(),
12498 ) {
12499 let name = self
12500 .state
12501 .placed_containers
12502 .iter()
12503 .find(|c| c.id == cid)
12504 .map(|c| c.display_name.clone())
12505 .unwrap_or_else(|| "container".into());
12506 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12507 ed.set_selected_withdraw_container(cid.clone());
12508 }
12509 self.state
12510 .push_log(format!("Route: withdraw source → {name}"));
12511 return;
12512 }
12513 }
12514
12515 enum Target {
12518 Bed(String),
12519 Container(String),
12520 Npc(String, String),
12521 Node(String, String),
12522 }
12523 let mut best: Option<(f32, u8, Target)> = None;
12524 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
12525 let better = match best {
12526 None => true,
12527 Some((bd, brank, _)) => {
12528 d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank)
12529 }
12530 };
12531 if better {
12532 *best = Some((d, rank, t));
12533 }
12534 };
12535 let inside = self.state.effective_inside_building();
12536 if let Some(bed_id) = wre::pick_lodging_container_at(
12537 &self.state.placed_containers,
12538 self.state.character_id,
12539 x,
12540 y,
12541 inside.as_deref(),
12542 ) {
12543 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
12544 let already_bed =
12547 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12548 ed.lodging_container_id.as_deref() == Some(bed_id.as_str())
12549 });
12550 if already_bed {
12551 consider(
12552 dist(x, y, c.x, c.y),
12553 1,
12554 Target::Container(bed_id),
12555 &mut best,
12556 );
12557 } else {
12558 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
12559 }
12560 }
12561 }
12562 if let Some(cid) = wre::pick_storage_container_at(
12563 &self.state.placed_containers,
12564 self.state.character_id,
12565 x,
12566 y,
12567 inside.as_deref(),
12568 ) {
12569 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
12570 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
12571 }
12572 }
12573 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12574 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
12575 consider(
12576 dist(x, y, n.x, n.y),
12577 2,
12578 Target::Npc(npc_id, label),
12579 &mut best,
12580 );
12581 }
12582 }
12583 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
12584 let d = dist(x, y, node.x, node.y);
12585 let label = resource_node_route_label(node);
12586 consider(d, 3, Target::Node(node.id.clone(), label), &mut best);
12587 }
12588
12589 match best.map(|(_, _, t)| t) {
12590 Some(Target::Bed(bed_id)) => {
12591 let name = self
12592 .state
12593 .placed_containers
12594 .iter()
12595 .find(|c| c.id == bed_id)
12596 .map(|c| c.display_name.clone())
12597 .unwrap_or_else(|| "camp bed".into());
12598 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12599 ed.lodging_container_id = Some(bed_id.clone());
12600 }
12601 self.state
12602 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
12603 }
12604 Some(Target::Container(cid)) => {
12605 let name = self
12606 .state
12607 .placed_containers
12608 .iter()
12609 .find(|c| c.id == cid)
12610 .map(|c| c.display_name.clone())
12611 .unwrap_or_else(|| "container".into());
12612 let added = self
12613 .state
12614 .worker_route_editor
12615 .as_mut()
12616 .is_some_and(|ed| ed.append_deposit_at(&cid));
12617 if added {
12618 self.state
12619 .push_log(format!("Route: + deposit at {name} ({cid})"));
12620 } else {
12621 self.state.push_log(format!(
12622 "Route: {name} already in route — selected it (d to remove)"
12623 ));
12624 }
12625 }
12626 Some(Target::Npc(npc_id, label)) => {
12627 let template = self.re_template_candidates().into_iter().next();
12630 let Some(template) = template else {
12631 self.state.push_log(
12632 "Route: no items in your storage to sell — stock a chest first".to_string(),
12633 );
12634 return;
12635 };
12636 let added = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
12637 ed.append_trade_with(template.clone(), Some(npc_id.clone()), true)
12638 });
12639 if added {
12640 self.state
12641 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
12642 } else {
12643 self.state.push_log(format!(
12644 "Route: {label} already sells {template} — selected it (d to remove)"
12645 ));
12646 }
12647 }
12648 Some(Target::Node(id, label)) => {
12649 let added = self
12650 .state
12651 .worker_route_editor
12652 .as_mut()
12653 .is_some_and(|ed| ed.append_harvest_node(&id));
12654 if added {
12655 self.state
12656 .push_log(format!("Route: + harvest node {label}"));
12657 } else {
12658 self.state.push_log(format!(
12659 "Route: {label} already in route — selected it (d to remove)"
12660 ));
12661 }
12662 }
12663 None => {}
12664 }
12665 }
12666
12667 pub fn worker_route_editor_select(&mut self, delta: i32) {
12668 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12669 return;
12670 };
12671 if ed.stops.is_empty() {
12672 return;
12673 }
12674 let n = ed.stops.len() as i32;
12675 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
12676 ed.selected_stop_index = next;
12677 }
12678
12679 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
12680 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12681 return;
12682 };
12683 if delta < 0 {
12684 ed.move_selected_up();
12685 } else if delta > 0 {
12686 ed.move_selected_down();
12687 }
12688 }
12689
12690 pub fn worker_route_editor_delete_selected(&mut self) {
12691 let removed = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
12692 let before = ed.stop_count();
12693 ed.remove_selected_stop();
12694 ed.stop_count() < before
12695 });
12696 if removed {
12697 self.state.push_log("Route: removed selected stop");
12698 }
12699 }
12700
12701 pub fn worker_route_editor_clear_stops(&mut self) {
12704 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12705 return;
12706 };
12707 if ed.stops.is_empty() {
12708 self.state
12709 .push_log("Route: already empty — s saves an idle worker".to_string());
12710 return;
12711 }
12712 ed.stops.clear();
12713 ed.selected_stop_index = 0;
12714 self.state.push_log(
12715 "Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string(),
12716 );
12717 }
12718
12719 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
12720 if self.state.pending_worker_job_ack.is_some() {
12721 anyhow::bail!("route save still pending — wait for server ack");
12722 }
12723 let Some(ed) = self.state.worker_route_editor.clone() else {
12724 anyhow::bail!("route editor not open");
12725 };
12726 let (job_yaml, idle) = if ed.stops.is_empty() {
12729 (ed.build_idle_job_yaml(), true)
12730 } else {
12731 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
12732 };
12733 let worker_id = ed.worker_instance_id.clone();
12734 let route_view = if idle { None } else { Some(ed.to_route_view()) };
12735 let mode = if idle {
12736 flatland_protocol::WorkerModeView::Idle
12737 } else {
12738 flatland_protocol::WorkerModeView::JobLoop
12739 };
12740 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
12741 .state
12742 .hired_workers
12743 .iter()
12744 .find(|w| w.instance_id == worker_id)
12745 .map(|w| {
12746 (
12747 w.route.clone(),
12748 w.mode,
12749 w.step_label.clone(),
12750 w.last_error.clone(),
12751 )
12752 })
12753 .unwrap_or((
12754 None,
12755 flatland_protocol::WorkerModeView::Idle,
12756 String::new(),
12757 None,
12758 ));
12759 self.seq += 1;
12760 let seq = self.seq;
12761 self.session
12762 .submit_intent(Intent::SetWorkerJob {
12763 entity_id: self.state.entity_id,
12764 worker_instance_id: worker_id.clone(),
12765 job_yaml,
12766 seq,
12767 })
12768 .await?;
12769 self.state.intents_sent += 1;
12770 if let Some(w) = self
12771 .state
12772 .hired_workers
12773 .iter_mut()
12774 .find(|w| w.instance_id == worker_id)
12775 {
12776 w.route = route_view;
12777 w.mode = mode;
12778 w.last_error = None;
12779 if idle {
12780 w.step_label.clear();
12781 w.route_stop_index = None;
12782 }
12783 }
12784 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
12785 seq,
12786 worker_instance_id: worker_id,
12787 worker_label: ed.worker_label.clone(),
12788 idle,
12789 stop_count: ed.stops.len(),
12790 prev_route,
12791 prev_mode,
12792 prev_step_label,
12793 prev_last_error,
12794 });
12795 self.state.push_log(format!(
12796 "Route: saving for {}… (waiting for server)",
12797 ed.worker_label
12798 ));
12799 Ok(())
12801 }
12802 pub fn quest_menu_move(&mut self, delta: i32) {
12803 let n = self.state.active_quest_entries().len();
12804 if n == 0 {
12805 return;
12806 }
12807 let idx = self.state.quest_menu_index as i32;
12808 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
12809 }
12810
12811 pub fn quest_menu_page(&mut self, pages: i32) {
12812 let n = self.state.active_quest_entries().len();
12813 self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
12814 }
12815
12816 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
12817 let Some(offer) = self.state.pending_quest_offer.clone() else {
12818 anyhow::bail!("no quest offer");
12819 };
12820 self.seq += 1;
12821 let seq = self.seq;
12822 self.session
12823 .submit_intent(Intent::AcceptQuest {
12824 entity_id: self.state.entity_id,
12825 quest_id: offer.quest_id,
12826 seq,
12827 })
12828 .await?;
12829 self.state.intents_sent += 1;
12830 Ok(())
12831 }
12832
12833 pub fn quest_offer_decline(&mut self) {
12834 self.state.show_quest_offer = false;
12835 self.state.pending_quest_offer = None;
12836 if !self.state.show_npc_chat
12837 && !self.state.show_shop_menu
12838 && self.state.npc_verb_target.is_some()
12839 {
12840 self.state.show_npc_verb_menu = true;
12841 }
12842 }
12843
12844 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
12845 if !self.state.show_quest_menu {
12846 return Ok(());
12847 }
12848 let active: Vec<_> = self
12849 .state
12850 .active_quest_entries()
12851 .into_iter()
12852 .cloned()
12853 .collect();
12854 let Some(entry) = active.get(self.state.quest_menu_index) else {
12855 return Ok(());
12856 };
12857 if self.state.quest_withdraw_confirm {
12858 if !entry.can_withdraw {
12859 anyhow::bail!("quest cannot be withdrawn");
12860 }
12861 self.seq += 1;
12862 let seq = self.seq;
12863 self.session
12864 .submit_intent(Intent::WithdrawQuest {
12865 entity_id: self.state.entity_id,
12866 quest_id: entry.quest_id.clone(),
12867 seq,
12868 })
12869 .await?;
12870 self.state.intents_sent += 1;
12871 self.state.quest_withdraw_confirm = false;
12872 return Ok(());
12873 }
12874 self.seq += 1;
12875 let seq = self.seq;
12876 self.session
12877 .submit_intent(Intent::TrackQuest {
12878 entity_id: self.state.entity_id,
12879 quest_id: entry.quest_id.clone(),
12880 seq,
12881 })
12882 .await?;
12883 self.state.intents_sent += 1;
12884 Ok(())
12885 }
12886
12887 pub fn quest_request_withdraw(&mut self) {
12888 if self.state.show_quest_menu {
12889 self.state.quest_withdraw_confirm = true;
12890 }
12891 }
12892
12893 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
12894 if !self.state.is_alive() {
12895 anyhow::bail!("you are dead");
12896 }
12897 let Some(catalog) = self.state.shop_catalog.clone() else {
12898 anyhow::bail!("no shop open");
12899 };
12900 self.seq += 1;
12901 let seq = self.seq;
12902 match self.state.shop_tab {
12903 ShopTab::Buy => {
12904 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
12905 anyhow::bail!("nothing selected");
12906 };
12907 if offer.already_owned {
12908 anyhow::bail!("already owned");
12909 }
12910 self.session
12911 .submit_intent(Intent::ShopBuy {
12912 entity_id: self.state.entity_id,
12913 npc_id: catalog.npc_id.clone(),
12914 offer_id: offer.offer_id.clone(),
12915 quantity: self.state.shop_quantity,
12916 seq,
12917 })
12918 .await?;
12919 }
12920 ShopTab::Sell => {
12921 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
12922 anyhow::bail!("nothing to sell");
12923 };
12924 if line.quantity == 0 {
12925 anyhow::bail!("you have no {}", line.label);
12926 }
12927 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
12928 self.session
12929 .submit_intent(Intent::ShopSell {
12930 entity_id: self.state.entity_id,
12931 npc_id: catalog.npc_id.clone(),
12932 template_id: line.template_id.clone(),
12933 quantity,
12934 seq,
12935 })
12936 .await?;
12937 }
12938 }
12939 self.state.intents_sent += 1;
12940 Ok(())
12941 }
12942
12943 pub fn craft_menu_move(&mut self, delta: i32) {
12944 let n = self.state.blueprints.len();
12945 if n == 0 {
12946 return;
12947 }
12948 let idx = self.state.craft_menu_index as i32;
12949 let next = (idx + delta).rem_euclid(n as i32);
12950 self.state.craft_menu_index = next as usize;
12951 self.state.clamp_craft_batch_quantity();
12952 }
12953
12954 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
12955 self.state.craft_batch_adjust_quantity(delta);
12956 }
12957
12958 pub fn craft_batch_set_max(&mut self) {
12959 self.state.craft_batch_set_max();
12960 }
12961
12962 pub fn craft_batch_set_min(&mut self) {
12963 self.state.craft_batch_set_min();
12964 }
12965
12966 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
12967 let Some(blueprint) = self
12968 .state
12969 .blueprints
12970 .get(self.state.craft_menu_index)
12971 .cloned()
12972 else {
12973 anyhow::bail!("no blueprints known");
12974 };
12975 if !self.state.can_craft_blueprint(&blueprint) {
12976 let hint = self
12977 .state
12978 .craft_missing_hint(&blueprint)
12979 .unwrap_or_else(|| "missing materials".into());
12980 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
12981 }
12982 let count = self.state.craft_batch_quantity;
12983 let max = self.state.max_craft_batches(&blueprint);
12984 if max == 0 {
12985 anyhow::bail!("cannot craft {}", blueprint.label);
12986 }
12987 let batches = count.min(max);
12988 self.craft(&blueprint.id, Some(batches)).await?;
12989 self.state.show_craft_menu = false;
12990 Ok(())
12991 }
12992
12993 pub async fn move_by(
12994 &mut self,
12995 forward: f32,
12996 strafe: f32,
12997 vertical: f32,
12998 sprint: bool,
12999 sneak: bool,
13000 ) -> anyhow::Result<()> {
13001 if !self.state.is_alive() {
13002 anyhow::bail!("you are dead");
13003 }
13004 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
13005 self.last_move_forward = forward;
13006 self.last_move_strafe = strafe;
13007 }
13008 self.seq += 1;
13009 self.session
13010 .submit_intent(Intent::Move {
13011 entity_id: self.state.entity_id,
13012 forward,
13013 strafe,
13014 vertical,
13015 sprint: sprint && !sneak,
13016 sneak,
13017 seq: self.seq,
13018 })
13019 .await?;
13020 self.state.intents_sent += 1;
13021 Ok(())
13022 }
13023
13024 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
13025 if !self.state.connected {
13026 crate::harvest_trace!("harvest_nearest rejected: not connected");
13027 anyhow::bail!("not connected");
13028 }
13029 if !self.state.is_alive() {
13030 crate::harvest_trace!("harvest_nearest rejected: player dead");
13031 anyhow::bail!("you are dead");
13032 }
13033 if self.state.harvest_in_progress {
13034 if self.state.harvest_state_stale() {
13035 self.state.clear_harvest_state();
13036 } else {
13037 anyhow::bail!("already harvesting");
13038 }
13039 }
13040 let (px, py) = self
13041 .state
13042 .player
13043 .as_ref()
13044 .map(|p| (p.transform.position.x, p.transform.position.y))
13045 .unwrap_or((0.0, 0.0));
13046
13047 let available = self
13048 .state
13049 .resource_nodes
13050 .iter()
13051 .filter(|n| !n.harvest_off)
13052 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
13053 .count();
13054 let node_id = self
13055 .state
13056 .resource_nodes
13057 .iter()
13058 .filter(|n| !n.harvest_off)
13059 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
13060 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
13061 .min_by(|a, b| {
13062 let da = distance(px, py, a.x, a.y);
13063 let db = distance(px, py, b.x, b.y);
13064 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
13065 })
13066 .map(|n| n.id.clone());
13067
13068 let Some(node_id) = node_id else {
13069 let has_loot = self
13070 .state
13071 .ground_drops
13072 .iter()
13073 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
13074 if has_loot {
13075 return self.pickup_nearest().await;
13076 }
13077 anyhow::bail!(
13078 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
13079 );
13080 };
13081
13082 self.seq += 1;
13083 let seq = self.seq;
13084 crate::harvest_trace!(
13085 entity_id = self.state.entity_id,
13086 node_id = %node_id,
13087 seq,
13088 px,
13089 py,
13090 available_nodes = available,
13091 "submitting harvest intent"
13092 );
13093 self.session
13094 .submit_intent(Intent::Harvest {
13095 entity_id: self.state.entity_id,
13096 node_id,
13097 seq,
13098 })
13099 .await?;
13100 self.state.intents_sent += 1;
13101 self.state.harvest_in_progress = true;
13102 self.state.harvest_started_at = Some(Instant::now());
13103 self.state.push_log("Harvesting…");
13104 crate::harvest_trace!(
13105 entity_id = self.state.entity_id,
13106 seq,
13107 "harvest intent queued to session"
13108 );
13109 Ok(())
13110 }
13111
13112 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
13113 if !self.state.is_alive() {
13114 anyhow::bail!("you are dead");
13115 }
13116 let blueprint_id = self
13117 .state
13118 .blueprints
13119 .iter()
13120 .find(|bp| self.state.can_craft_blueprint(bp))
13121 .map(|bp| bp.id.clone())
13122 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
13123 self.craft(&blueprint_id, None).await
13124 }
13125
13126 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
13127 if !self.state.is_alive() {
13128 anyhow::bail!("you are dead");
13129 }
13130 self.seq += 1;
13131 self.session
13132 .submit_intent(Intent::Craft {
13133 entity_id: self.state.entity_id,
13134 blueprint_id: blueprint_id.to_string(),
13135 count,
13136 seq: self.seq,
13137 })
13138 .await?;
13139 self.state.intents_sent += 1;
13140 let (label, batches) = self
13141 .state
13142 .blueprints
13143 .iter()
13144 .find(|b| b.id == blueprint_id)
13145 .map(|b| {
13146 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
13147 (b.label.as_str(), n)
13148 })
13149 .unwrap_or((blueprint_id, count.unwrap_or(1)));
13150 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
13151 Ok(())
13152 }
13153
13154 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
13155 if !self.state.is_alive() {
13156 anyhow::bail!("you are dead");
13157 }
13158 let target_id = match self.state.nearest_interact_target() {
13159 Some(id) => id,
13160 None => {
13161 anyhow::bail!("nothing to interact with nearby");
13162 }
13163 };
13164 if self.state.npcs.iter().any(|n| n.id == target_id) {
13165 self.state.show_npc_verb_menu = true;
13166 self.state.npc_verb_target = Some(target_id);
13167 self.state.npc_verb_index = 0;
13168 return Ok(());
13169 }
13170 if self
13171 .state
13172 .hired_workers
13173 .iter()
13174 .any(|w| w.instance_id == target_id)
13175 {
13176 return self.open_workers_menu_for(&target_id).await;
13177 }
13178 if let Ok(peer_id) = target_id.parse::<EntityId>() {
13179 if self
13180 .state
13181 .hired_workers
13182 .iter()
13183 .any(|w| w.entity_id == peer_id)
13184 {
13185 if let Some(w) = self
13186 .state
13187 .hired_workers
13188 .iter()
13189 .find(|w| w.entity_id == peer_id)
13190 {
13191 let id = w.instance_id.clone();
13192 return self.open_workers_menu_for(&id).await;
13193 }
13194 }
13195 if let Some(entity) = self
13196 .state
13197 .entities
13198 .iter()
13199 .find(|e| e.id == peer_id && e.id != self.state.entity_id)
13200 {
13201 self.state.player_verbs.open_for(peer_id, &entity.label);
13202 return Ok(());
13203 }
13204 }
13205 self.seq += 1;
13206 self.session
13207 .submit_intent(Intent::Interact {
13208 entity_id: self.state.entity_id,
13209 target_id: target_id.clone(),
13210 seq: self.seq,
13211 })
13212 .await?;
13213 self.state.intents_sent += 1;
13214 Ok(())
13215 }
13216
13217 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
13219 if !self.state.is_alive() {
13220 anyhow::bail!("you are dead");
13221 }
13222 let (px, py) = self.state.player_position();
13223 let has_loot = self
13224 .state
13225 .ground_drops
13226 .iter()
13227 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
13228 if has_loot {
13229 return self.pickup_nearest().await;
13230 }
13231 if self
13232 .state
13233 .placed_containers
13234 .iter()
13235 .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
13236 {
13237 return self.pickup_nearest_container().await;
13238 }
13239
13240 if let Some(plot) = self.state.my_plot_under_player().cloned() {
13241 const SELL_WINDOW: Duration = Duration::from_millis(1200);
13243 let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
13244 && self
13245 .state
13246 .sell_plot_armed_at
13247 .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
13248 if sell_armed {
13249 return self.confirm_sell_plot_to_crown(plot.plot_id).await;
13250 }
13251 self.state.sell_plot_confirm = None;
13252 self.state.sell_plot_armed_at = None;
13253
13254 let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
13257 self.state.npcs.iter().any(|n| n.id == id)
13258 || self.state.hired_workers.iter().any(|w| w.instance_id == id)
13259 || self.state.doors.iter().any(|d| d.id == id)
13260 || self.state.interactables.iter().any(|i| {
13261 i.id == id
13262 && matches!(i.kind.as_str(), "quest_board" | "well" | "exit" | "enter")
13263 })
13264 || id.parse::<EntityId>().is_ok_and(|eid| {
13265 self.state
13266 .entities
13267 .iter()
13268 .any(|e| e.id == eid && e.id != self.state.entity_id)
13269 })
13270 });
13271 if !blocking_interact {
13272 match self.harvest_nearest().await {
13274 Ok(()) => return Ok(()),
13275 Err(err) => {
13276 let msg = err.to_string();
13277 if !(msg.contains("no harvestable")
13278 || msg.contains("press p")
13279 || msg.contains("press f")
13280 || msg.contains("nothing"))
13281 {
13282 return Err(err);
13283 }
13284 }
13285 }
13286 return Ok(());
13287 }
13288 }
13289 if self.state.nearest_interact_target().is_some() {
13290 return self.interact_nearest().await;
13291 }
13292 if let Some((label, dist)) = self.state.nearest_quest_board() {
13295 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
13296 anyhow::bail!(
13297 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
13298 );
13299 }
13300 }
13301
13302 match self.harvest_nearest().await {
13303 Ok(()) => Ok(()),
13304 Err(err) => {
13305 let msg = err.to_string();
13306 if msg.contains("no harvestable")
13307 || msg.contains("press p")
13308 || msg.contains("press f")
13309 {
13310 anyhow::bail!(
13311 "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
13312 );
13313 }
13314 Err(err)
13315 }
13316 }
13317 }
13318
13319 pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
13321 if !self.state.is_alive() {
13322 anyhow::bail!("you are dead");
13323 }
13324 if self.state.claim_mode.is_some() {
13325 anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
13326 }
13327 let zone = self
13328 .state
13329 .free_property_zone_under_player()
13330 .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
13331 let zone_id = zone.id.clone();
13332 let label = zone
13333 .label
13334 .as_deref()
13335 .filter(|s| !s.trim().is_empty())
13336 .unwrap_or(zone.id.as_str())
13337 .to_string();
13338 self.enter_claim_mode(&zone_id);
13339 self.state.push_log(format!(
13340 "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
13341 ));
13342 Ok(())
13343 }
13344
13345 pub fn enter_claim_mode(&mut self, zone_id: &str) {
13347 let Some(zone) = self
13348 .state
13349 .property_zones
13350 .iter()
13351 .find(|z| z.id == zone_id)
13352 .cloned()
13353 else {
13354 self.state.push_log("unknown property zone");
13355 return;
13356 };
13357 self.state.sell_plot_confirm = None;
13358 self.state.sell_plot_armed_at = None;
13359 let min_area = self
13360 .state
13361 .property_plot_settings
13362 .as_ref()
13363 .map(|s| s.min_plot_area_m2)
13364 .unwrap_or(4.0)
13365 .max(1.0);
13366 let min_side = min_area.sqrt().ceil().max(1.0) as u32;
13367 let side = 4u32.max(min_side);
13368 let (px, py) = self.state.player_position();
13369 let anchor_x = px.floor();
13370 let anchor_y = py.floor();
13371 self.state.claim_mode = Some(ClaimModeState {
13372 zone_id: zone.id.clone(),
13373 width_m: side,
13374 height_m: side,
13375 anchor_x,
13376 anchor_y,
13377 });
13378 let label = zone
13379 .label
13380 .as_deref()
13381 .filter(|s| !s.trim().is_empty())
13382 .unwrap_or(zone.id.as_str());
13383 self.state.push_log(format!(
13384 "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
13385 ));
13386 }
13387
13388 pub fn cancel_claim_mode(&mut self) {
13389 if self.state.claim_mode.take().is_some() {
13390 self.state.push_log("Claim cancelled");
13391 }
13392 }
13393
13394 pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
13396 if !self.state.is_alive() {
13397 anyhow::bail!("you are dead");
13398 }
13399 if self.state.relocate_mode.is_some() {
13400 anyhow::bail!("already relocating — Enter confirm, Esc cancel");
13401 }
13402 if self.state.claim_mode.is_some() {
13403 anyhow::bail!("finish or cancel claim mode first");
13404 }
13405 let chest = self
13406 .state
13407 .placed_containers
13408 .iter()
13409 .find(|c| c.id == container_id)
13410 .cloned()
13411 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
13412 let (px, py) = self.state.player_position();
13413 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
13414 anyhow::bail!("too far from {}", chest.display_name);
13415 }
13416 if chest.locked && !chest.accessible {
13417 anyhow::bail!(
13418 "need the matching key for {} before moving it",
13419 chest.display_name
13420 );
13421 }
13422 let label = if chest.display_name.trim().is_empty() {
13423 chest.template_id.clone()
13424 } else {
13425 chest.display_name.clone()
13426 };
13427 self.state.relocate_mode = Some(RelocateModeState {
13428 container_id: chest.id.clone(),
13429 label: label.clone(),
13430 cursor_x: chest.x.floor() + 0.5,
13431 cursor_y: chest.y.floor() + 0.5,
13432 });
13433 self.state.push_log(format!(
13434 "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
13435 ));
13436 Ok(())
13437 }
13438
13439 pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
13441 let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
13442 anyhow::bail!("no chest nearby to relocate");
13443 };
13444 if chest.locked && !chest.accessible {
13445 anyhow::bail!(
13446 "need the matching key for {} before moving it",
13447 chest.display_name
13448 );
13449 }
13450 self.begin_relocate_container(&chest.id)
13453 }
13454
13455 pub fn cancel_relocate_mode(&mut self) {
13456 if self.state.relocate_mode.take().is_some() {
13457 self.state.push_log("Relocate cancelled");
13458 }
13459 }
13460
13461 pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
13462 let Some(mode) = self.state.relocate_mode.as_mut() else {
13463 return;
13464 };
13465 let max_x = self.state.world_width_m.max(1.0);
13466 let max_y = self.state.world_height_m.max(1.0);
13467 let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
13468 let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
13469 mode.cursor_x = nx.floor() + 0.5;
13470 mode.cursor_y = ny.floor() + 0.5;
13471 }
13472
13473 pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
13474 let Some(mode) = self.state.relocate_mode.as_mut() else {
13475 return;
13476 };
13477 let max_x = self.state.world_width_m.max(1.0);
13478 let max_y = self.state.world_height_m.max(1.0);
13479 mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
13480 mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
13481 }
13482
13483 pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
13484 if !self.state.is_alive() {
13485 anyhow::bail!("you are dead");
13486 }
13487 let Some(mode) = self.state.relocate_mode.clone() else {
13488 anyhow::bail!("not relocating");
13489 };
13490 let (px, py) = self.state.player_position();
13491 let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
13492 if dist > 8.0 {
13493 anyhow::bail!("destination too far (max 8 m)");
13494 }
13495 self.seq += 1;
13496 self.session
13497 .submit_intent(Intent::MovePlacedContainer {
13498 entity_id: self.state.entity_id,
13499 container_id: mode.container_id.clone(),
13500 x: mode.cursor_x,
13501 y: mode.cursor_y,
13502 seq: self.seq,
13503 })
13504 .await?;
13505 self.state.intents_sent += 1;
13506 self.state.relocate_mode = None;
13507 self.state.push_log(format!("Moving {}…", mode.label));
13508 Ok(())
13509 }
13510
13511 pub fn claim_set_preset(&mut self, w: u32, h: u32) {
13512 let Some(mode) = self.state.claim_mode.as_mut() else {
13513 return;
13514 };
13515 mode.width_m = w.max(1);
13516 mode.height_m = h.max(1);
13517 }
13518
13519 pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
13520 let Some(mode) = self.state.claim_mode.as_mut() else {
13521 return;
13522 };
13523 let w = (mode.width_m as i32 + dw).max(1) as u32;
13524 let h = (mode.height_m as i32 + dh).max(1) as u32;
13525 mode.width_m = w;
13526 mode.height_m = h;
13527 }
13528
13529 pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
13531 let Some(mode) = self.state.claim_mode.as_mut() else {
13532 return;
13533 };
13534 let max_x = self.state.world_width_m.max(1.0);
13535 let max_y = self.state.world_height_m.max(1.0);
13536 let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
13537 let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
13538 mode.anchor_x = nx.floor();
13539 mode.anchor_y = ny.floor();
13540 }
13541
13542 pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
13543 if !self.state.is_alive() {
13544 anyhow::bail!("you are dead");
13545 }
13546 let Some(mode) = self.state.claim_mode.clone() else {
13547 anyhow::bail!("not in claim mode");
13548 };
13549 let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
13550 self.state.claim_quote()
13551 else {
13552 anyhow::bail!("cannot quote claim");
13553 };
13554 if !valid {
13555 anyhow::bail!(reason);
13556 }
13557 if !can_afford {
13558 anyhow::bail!(
13559 "not enough copper (need {})",
13560 crate::currency::format_copper(purchase)
13561 );
13562 }
13563 let (x0, y0, x1, y1) = self
13564 .state
13565 .claim_footprint_rect()
13566 .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
13567 let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
13568 self.seq += 1;
13569 self.session
13570 .submit_intent(Intent::BuyPlot {
13571 entity_id: self.state.entity_id,
13572 zone_id: mode.zone_id,
13573 x0,
13574 y0,
13575 x1,
13576 y1,
13577 seq: self.seq,
13578 })
13579 .await?;
13580 self.state.intents_sent += 1;
13581 self.state.claim_mode = None;
13582 self.state.push_log(format!(
13583 "Buying plot for {}",
13584 crate::currency::format_copper(purchase)
13585 ));
13586 Ok(())
13587 }
13588
13589 pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
13590 if !self.state.is_alive() {
13591 anyhow::bail!("you are dead");
13592 }
13593 let zone_id = self
13594 .state
13595 .claim_mode
13596 .as_ref()
13597 .map(|m| m.zone_id.clone())
13598 .or_else(|| {
13599 self.state
13600 .free_property_zone_under_player()
13601 .map(|z| z.id.clone())
13602 })
13603 .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
13604 self.seq += 1;
13605 self.session
13606 .submit_intent(Intent::BuyPlotAllFree {
13607 entity_id: self.state.entity_id,
13608 zone_id,
13609 seq: self.seq,
13610 })
13611 .await?;
13612 self.state.intents_sent += 1;
13613 self.state.claim_mode = None;
13614 self.state.push_log("Claiming largest free plot…");
13615 Ok(())
13616 }
13617
13618 pub async fn confirm_sell_plot_to_crown(&mut self, plot_id: uuid::Uuid) -> anyhow::Result<()> {
13619 if !self.state.is_alive() {
13620 anyhow::bail!("you are dead");
13621 }
13622 self.seq += 1;
13623 self.session
13624 .submit_intent(Intent::SellPlotToCrown {
13625 entity_id: self.state.entity_id,
13626 plot_id,
13627 seq: self.seq,
13628 })
13629 .await?;
13630 self.state.intents_sent += 1;
13631 self.state.sell_plot_confirm = None;
13632 self.state.sell_plot_armed_at = None;
13633 self.state.push_log("Selling plot to the crown…");
13634 Ok(())
13635 }
13636
13637 pub async fn set_plot_farm_public(
13638 &mut self,
13639 plot_id: uuid::Uuid,
13640 public: bool,
13641 public_tax_discount_bps: u32,
13642 ) -> anyhow::Result<()> {
13643 self.seq += 1;
13644 self.session
13645 .submit_intent(Intent::SetPlotFarmPublic {
13646 entity_id: self.state.entity_id,
13647 plot_id,
13648 public,
13649 public_tax_discount_bps,
13650 seq: self.seq,
13651 })
13652 .await?;
13653 self.state.intents_sent += 1;
13654 Ok(())
13655 }
13656
13657 pub async fn plot_farm_allow_upsert(
13658 &mut self,
13659 plot_id: uuid::Uuid,
13660 character_id: Option<uuid::Uuid>,
13661 character_name: String,
13662 tax_discount_bps: u32,
13663 ) -> anyhow::Result<()> {
13664 self.seq += 1;
13665 self.session
13666 .submit_intent(Intent::PlotFarmAllowUpsert {
13667 entity_id: self.state.entity_id,
13668 plot_id,
13669 character_id,
13670 character_name,
13671 tax_discount_bps,
13672 seq: self.seq,
13673 })
13674 .await?;
13675 self.state.intents_sent += 1;
13676 Ok(())
13677 }
13678
13679 pub async fn plot_farm_allow_remove(
13680 &mut self,
13681 plot_id: uuid::Uuid,
13682 character_id: uuid::Uuid,
13683 ) -> anyhow::Result<()> {
13684 self.seq += 1;
13685 self.session
13686 .submit_intent(Intent::PlotFarmAllowRemove {
13687 entity_id: self.state.entity_id,
13688 plot_id,
13689 character_id,
13690 seq: self.seq,
13691 })
13692 .await?;
13693 self.state.intents_sent += 1;
13694 Ok(())
13695 }
13696
13697 pub fn open_farm_access_panel(&mut self) {
13698 let Some(plot) = self.state.my_plot_under_player() else {
13699 self.state
13700 .push_log("Stand on your deed plot to manage farm access");
13701 return;
13702 };
13703 self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
13704 self.state.farm_access_index = 0;
13705 self.state.show_farm_access = true;
13706 }
13707
13708 pub fn close_farm_access_panel(&mut self) {
13709 self.state.show_farm_access = false;
13710 self.state.farm_access_name_draft.clear();
13711 self.state.farm_access_index = 0;
13712 }
13713
13714 pub fn farm_access_move(&mut self, delta: i32) {
13715 let n = self.farm_access_row_count().max(1);
13716 let idx = self.state.farm_access_index as i32 + delta;
13717 self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
13718 }
13719
13720 pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
13721 let Some(plot) = self.state.my_plot_under_player() else {
13722 return vec![FarmAccessRow::PublicToggle];
13723 };
13724 let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
13725 for g in &plot.farm_allow {
13726 rows.push(FarmAccessRow::AllowRemove {
13727 character_id: g.character_id,
13728 label: if g.character_label.trim().is_empty() {
13729 g.character_id.to_string()[..8].to_string()
13730 } else {
13731 g.character_label.clone()
13732 },
13733 tax_discount_bps: g.tax_discount_bps,
13734 });
13735 }
13736 for e in &self.state.entities {
13737 if e.id == self.state.entity_id || e.label.trim().is_empty() {
13738 continue;
13739 }
13740 if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
13741 continue;
13742 }
13743 if self
13744 .state
13745 .npcs
13746 .iter()
13747 .any(|n| n.id == e.label || n.label == e.label)
13748 {
13749 continue;
13750 }
13751 if plot
13752 .farm_allow
13753 .iter()
13754 .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
13755 {
13756 continue;
13757 }
13758 rows.push(FarmAccessRow::NearbyAdd {
13759 name: e.label.clone(),
13760 });
13761 }
13762 rows
13763 }
13764
13765 pub fn farm_access_row_count(&self) -> usize {
13766 self.farm_access_rows().len().max(1)
13767 }
13768
13769 pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
13770 let Some(plot) = self.state.my_plot_under_player().cloned() else {
13771 self.close_farm_access_panel();
13772 return Ok(());
13773 };
13774 let rows = self.farm_access_rows();
13775 let Some(row) = rows.get(self.state.farm_access_index) else {
13776 return Ok(());
13777 };
13778 match row {
13779 FarmAccessRow::PublicToggle => {
13780 self.set_plot_farm_public(
13781 plot.plot_id,
13782 !plot.farm_public,
13783 plot.public_tax_discount_bps,
13784 )
13785 .await
13786 }
13787 FarmAccessRow::PublicDiscount => Ok(()),
13788 FarmAccessRow::AllowRemove { character_id, .. } => {
13789 self.plot_farm_allow_remove(plot.plot_id, *character_id)
13790 .await
13791 }
13792 FarmAccessRow::NearbyAdd { name } => {
13793 let disc = self
13794 .state
13795 .farm_access_discount_bps
13796 .max(plot.public_tax_discount_bps);
13797 self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
13798 .await
13799 }
13800 }
13801 }
13802
13803 pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
13804 let Some(plot) = self.state.my_plot_under_player().cloned() else {
13805 return Ok(());
13806 };
13807 let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
13808 self.state.farm_access_discount_bps = next;
13809 self.state.farm_access_index = 1;
13810 self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
13811 .await
13812 }
13813
13814 pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
13816 if self.state.farmable_plot_under_player().is_none() {
13817 anyhow::bail!("stand on a farmable plot to cultivate");
13818 }
13819 let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
13820 let (px, py) = self.state.player_position();
13821 if self
13822 .state
13823 .terrain_at(px, py)
13824 .is_some_and(|k| k == TerrainKindView::Tilled)
13825 {
13826 anyhow::bail!("already tilled — stand on bare soil and press c");
13827 }
13828 anyhow::bail!("cannot till this cell — move onto soil on your plot");
13829 };
13830 self.cultivate_at(tx, ty).await
13831 }
13832
13833 pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
13835 if self.state.farmable_plot_under_player().is_none() {
13836 anyhow::bail!("stand on a farmable plot to plant");
13837 }
13838 if !self.state.underfoot_free_tilled_plant_slot() {
13839 anyhow::bail!("stand on empty tilled soil and press p");
13840 }
13841 let seeds = self.state.farm_seed_entries();
13842 if seeds.is_empty() {
13843 anyhow::bail!("no seeds in inventory — buy seeds from Eli");
13844 }
13845 if seeds.len() == 1 {
13846 return self.plant_seeds(seeds[0].0.clone(), 1).await;
13847 }
13848 self.open_plant_menu();
13849 Ok(())
13850 }
13851
13852 pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
13854 let Some(plot) = self.state.my_plot_under_player() else {
13855 anyhow::bail!("stand on your plot to build");
13856 };
13857 if plot.building_id.is_some() {
13858 anyhow::bail!("this plot already has a building");
13859 }
13860 let building_now = self
13861 .state
13862 .timed_channel
13863 .as_ref()
13864 .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
13865 if !building_now && self.state.building_materials.is_empty() {
13866 anyhow::bail!("no building materials loaded — wait a moment and try again");
13867 }
13868 self.state.show_plot_build_menu = true;
13869 self.state.show_craft_menu = false;
13870 self.state.show_shop_menu = false;
13871 self.state.shop_catalog = None;
13872 self.state.show_stats = false;
13873 self.state.show_inventory_menu = false;
13874 self.state.plot_build_focus_wall = true;
13875 let walls = self.state.plot_build_wall_options().len();
13876 let roofs = self.state.plot_build_roof_options().len();
13877 if walls > 0 {
13878 self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
13879 } else {
13880 self.state.plot_build_wall_index = 0;
13881 }
13882 if roofs > 0 {
13883 self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
13884 } else {
13885 self.state.plot_build_roof_index = 0;
13886 }
13887 Ok(())
13888 }
13889
13890 pub fn close_plot_build_menu(&mut self) {
13891 self.state.show_plot_build_menu = false;
13892 }
13893
13894 pub fn plot_build_menu_move(&mut self, delta: i32) {
13895 let walls = self.state.plot_build_wall_options();
13896 let roofs = self.state.plot_build_roof_options();
13897 if self.state.plot_build_focus_wall {
13898 if walls.is_empty() {
13899 return;
13900 }
13901 let n = walls.len() as i32;
13902 let cur = self.state.plot_build_wall_index as i32;
13903 self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
13904 } else {
13905 if roofs.is_empty() {
13906 return;
13907 }
13908 let n = roofs.len() as i32;
13909 let cur = self.state.plot_build_roof_index as i32;
13910 self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
13911 }
13912 }
13913
13914 pub fn plot_build_menu_toggle_focus(&mut self) {
13915 self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
13916 }
13917
13918 pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
13920 let wall = self
13921 .state
13922 .plot_build_selected_wall()
13923 .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
13924 .id
13925 .clone();
13926 let roof = self
13927 .state
13928 .plot_build_selected_roof()
13929 .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
13930 .id
13931 .clone();
13932 self.start_plot_build(&wall, &roof).await
13934 }
13935
13936 pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
13938 self.seq += 1;
13939 self.session
13940 .submit_intent(Intent::CancelPlotBuild {
13941 entity_id: self.state.entity_id,
13942 seq: self.seq,
13943 })
13944 .await?;
13945 self.state.intents_sent += 1;
13946 Ok(())
13947 }
13948
13949 pub async fn start_plot_build(
13951 &mut self,
13952 wall_material_id: &str,
13953 roof_material_id: &str,
13954 ) -> anyhow::Result<()> {
13955 let Some(plot) = self.state.my_plot_under_player() else {
13956 anyhow::bail!("stand on your plot to build");
13957 };
13958 if plot.building_id.is_some() {
13959 anyhow::bail!("this plot already has a building");
13960 }
13961 let plot_id = plot.plot_id;
13962 self.seq += 1;
13963 self.session
13964 .submit_intent(Intent::StartPlotBuild {
13965 entity_id: self.state.entity_id,
13966 plot_id,
13967 wall_material_id: wall_material_id.to_string(),
13968 roof_material_id: roof_material_id.to_string(),
13969 seq: self.seq,
13970 })
13971 .await?;
13972 self.state.intents_sent += 1;
13973 Ok(())
13974 }
13975
13976 pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
13978 let (px, py) = self.state.player_position();
13979 let mut best: Option<(f32, String, bool)> = None;
13980 for d in &self.state.doors {
13981 if d.lock_id.is_none() {
13982 continue;
13983 }
13984 let dist = (d.x - px).hypot(d.y - py);
13985 if dist > DOOR_INTERACTION_RADIUS_M {
13986 continue;
13987 }
13988 if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
13989 best = Some((dist, d.id.clone(), d.locked));
13990 }
13991 }
13992 let Some((_, door_id, locked_now)) = best else {
13993 anyhow::bail!("no lockable door nearby");
13994 };
13995 let locked = !locked_now;
13996 self.seq += 1;
13997 self.session
13998 .submit_intent(Intent::SetDoorLocked {
13999 entity_id: self.state.entity_id,
14000 door_id,
14001 locked,
14002 seq: self.seq,
14003 })
14004 .await?;
14005 self.state.intents_sent += 1;
14006 Ok(())
14007 }
14008
14009 pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
14011 if !self.state.is_alive() {
14012 anyhow::bail!("you are dead");
14013 }
14014 if self.state.effective_inside_building().is_some() {
14015 anyhow::bail!("already inside");
14016 }
14017 let (px, py) = self.state.player_position();
14018 let mut best: Option<(f32, String)> = None;
14019 for d in &self.state.doors {
14020 if !d.open || d.locked {
14021 continue;
14022 }
14023 let player_house = self
14024 .state
14025 .buildings
14026 .iter()
14027 .find(|b| b.id == d.building_id)
14028 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
14029 if !player_house {
14030 continue;
14031 }
14032 let dist = (d.x - px).hypot(d.y - py);
14033 if dist > DOOR_INTERACTION_RADIUS_M {
14034 continue;
14035 }
14036 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
14037 best = Some((dist, d.id.clone()));
14038 }
14039 }
14040 let Some((_, door_id)) = best else {
14041 anyhow::bail!("no open house door nearby — open with f first");
14042 };
14043 self.seq += 1;
14044 self.session
14045 .submit_intent(Intent::EnterBuildingDoor {
14046 entity_id: self.state.entity_id,
14047 door_id,
14048 seq: self.seq,
14049 })
14050 .await?;
14051 self.state.intents_sent += 1;
14052 Ok(())
14053 }
14054
14055 pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
14058 if !self.state.is_alive() {
14059 anyhow::bail!("you are dead");
14060 }
14061 let Some(bid) = self.state.effective_inside_building() else {
14062 anyhow::bail!("not inside a building");
14063 };
14064 let (px, py) = self.state.player_position();
14065 let mut best: Option<(f32, String)> = None;
14066 for d in &self.state.doors {
14067 if d.building_id != bid || d.portal.is_none() {
14068 continue;
14069 }
14070 let player_house = self
14071 .state
14072 .buildings
14073 .iter()
14074 .find(|b| b.id == d.building_id)
14075 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
14076 if !player_house {
14077 continue;
14078 }
14079 let dist = (d.x - px).hypot(d.y - py);
14080 if dist > 1.5 {
14081 continue;
14082 }
14083 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
14084 best = Some((dist, d.id.clone()));
14085 }
14086 }
14087 let Some((_, door_id)) = best else {
14088 anyhow::bail!("stand by the door to exit");
14089 };
14090 self.seq += 1;
14091 self.session
14092 .submit_intent(Intent::ExitBuildingDoor {
14093 entity_id: self.state.entity_id,
14094 door_id,
14095 seq: self.seq,
14096 })
14097 .await?;
14098 self.state.intents_sent += 1;
14099 Ok(())
14100 }
14101
14102 pub async fn confirm_interior_edit(
14104 &mut self,
14105 building_id: String,
14106 rooms: Vec<flatland_protocol::InteriorRoomEdit>,
14107 room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
14108 ) -> anyhow::Result<()> {
14109 self.seq += 1;
14110 self.session
14111 .submit_intent(Intent::ConfirmInteriorEdit {
14112 entity_id: self.state.entity_id,
14113 building_id,
14114 rooms,
14115 room_doors,
14116 seq: self.seq,
14117 })
14118 .await?;
14119 self.state.intents_sent += 1;
14120 Ok(())
14121 }
14122
14123 pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
14124 if !self.state.is_alive() {
14125 anyhow::bail!("you are dead");
14126 }
14127 self.seq += 1;
14128 self.session
14129 .submit_intent(Intent::Cultivate {
14130 entity_id: self.state.entity_id,
14131 x,
14132 y,
14133 seq: self.seq,
14134 })
14135 .await?;
14136 self.state.intents_sent += 1;
14137 Ok(())
14138 }
14139
14140 pub async fn plant_seeds(
14141 &mut self,
14142 seed_template_id: String,
14143 quantity: u32,
14144 ) -> anyhow::Result<()> {
14145 if !self.state.is_alive() {
14146 anyhow::bail!("you are dead");
14147 }
14148 self.seq += 1;
14149 self.session
14150 .submit_intent(Intent::PlantSeeds {
14151 entity_id: self.state.entity_id,
14152 seed_template_id: seed_template_id.clone(),
14153 quantity,
14154 seq: self.seq,
14155 })
14156 .await?;
14157 self.state.intents_sent += 1;
14158 self.state
14159 .push_log(format!("Planting {quantity}× {seed_template_id}…"));
14160 Ok(())
14161 }
14162
14163 pub fn open_plant_menu(&mut self) {
14164 if self.state.farm_seed_entries().is_empty() {
14165 self.state.push_log("No seeds in inventory to plant");
14166 return;
14167 }
14168 self.state.show_plant_menu = true;
14169 self.state.plant_menu_index = 0;
14170 self.state.plant_quantity = 1;
14171 self.state.clamp_plant_menu();
14172 }
14173
14174 pub fn close_plant_menu(&mut self) {
14175 self.state.show_plant_menu = false;
14176 }
14177
14178 pub fn plant_menu_move(&mut self, delta: i32) {
14179 let n = self.state.farm_seed_entries().len();
14180 if n == 0 {
14181 return;
14182 }
14183 let idx = self.state.plant_menu_index as i32 + delta;
14184 self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
14185 self.state.clamp_plant_menu();
14186 }
14187
14188 pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
14189 let next = self.state.plant_quantity as i32 + delta;
14190 self.state.plant_quantity = next.max(1) as u32;
14191 self.state.clamp_plant_menu();
14192 }
14193
14194 pub fn plant_menu_set_quantity_max(&mut self) {
14195 if let Some((_, max, _)) = self.state.plant_menu_selection() {
14196 self.state.plant_quantity = max;
14197 }
14198 self.state.clamp_plant_menu();
14199 }
14200
14201 pub fn plant_menu_set_quantity_min(&mut self) {
14202 self.state.plant_quantity = 1;
14203 self.state.clamp_plant_menu();
14204 }
14205
14206 pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
14207 let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
14208 self.close_plant_menu();
14209 anyhow::bail!("no seeds to plant");
14210 };
14211 self.close_plant_menu();
14212 self.plant_seeds(seed, qty).await?;
14213 self.state.push_log(format!("Planted {qty}× {label}"));
14214 Ok(())
14215 }
14216
14217 pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
14220 if !self.state.is_alive() {
14221 anyhow::bail!("you are dead");
14222 }
14223 let binding = self
14224 .state
14225 .hotbar_ability(slot)
14226 .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
14227 .to_string();
14228 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
14229 let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
14230 if qty == 0 {
14231 anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
14232 }
14233 return self.use_item(template_id).await;
14234 }
14235 let ability_id = binding;
14236 if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
14237 return self
14238 .cast_ability(&ability_id, Some(self.state.entity_id))
14239 .await;
14240 }
14241 let is_heal = ability_id == "heal_touch"
14242 || self
14243 .state
14244 .ability_meta
14245 .get(&ability_id)
14246 .map(|meta| meta.is_heal)
14247 .unwrap_or(false);
14248 let target = if is_heal {
14249 Some(
14250 self.state
14251 .target_for_slot(2)
14252 .unwrap_or(self.state.entity_id),
14253 )
14254 } else {
14255 self.state
14256 .target_for_slot(1)
14257 .or_else(|| self.state.target_for_slot(2))
14258 };
14259 let Some(target_id) = target else {
14260 anyhow::bail!("no target — Tab to select, then press the hotbar key");
14261 };
14262 self.cast_ability(&ability_id, Some(target_id)).await
14263 }
14264
14265 pub async fn set_hotbar_slot(
14268 &mut self,
14269 slot: u8,
14270 ability_id: Option<&str>,
14271 ) -> anyhow::Result<()> {
14272 if !self.state.is_alive() {
14273 anyhow::bail!("you are dead");
14274 }
14275 if !(1..=9).contains(&slot) {
14276 anyhow::bail!("hotbar slot must be 1–9");
14277 }
14278 let ability_id = ability_id
14279 .map(str::trim)
14280 .filter(|id| !id.is_empty())
14281 .map(str::to_string);
14282 self.seq += 1;
14283 self.session
14284 .submit_intent(Intent::SetHotbarSlot {
14285 entity_id: self.state.entity_id,
14286 slot,
14287 ability_id: ability_id.clone(),
14288 seq: self.seq,
14289 })
14290 .await?;
14291 self.state.intents_sent += 1;
14292 let idx = (slot - 1) as usize;
14293 if self.state.hotbar.len() < 9 {
14294 self.state.hotbar.resize(9, None);
14295 }
14296 if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
14297 *slot_mut = ability_id.clone();
14298 }
14299 match ability_id {
14300 Some(id) => {
14301 let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
14302 format!("use {tid}")
14303 } else {
14304 id
14305 };
14306 self.state.push_log(format!("Hotbar {slot} → {label}"))
14307 }
14308 None => self.state.push_log(format!("Hotbar {slot} cleared")),
14309 }
14310 Ok(())
14311 }
14312
14313 pub fn npc_verb_options(&self) -> Vec<&'static str> {
14314 self.state.npc_verb_options()
14315 }
14316
14317 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
14318 let Some(npc_id) = self.state.npc_verb_target.clone() else {
14319 return Ok(());
14320 };
14321 let options = self.npc_verb_options();
14322 let choice = options
14323 .get(self.state.npc_verb_index)
14324 .copied()
14325 .unwrap_or("Talk");
14326 self.seq += 1;
14327 match choice {
14328 "Trade" | "Bank" | "Storage" | "Market" => {
14329 self.session
14330 .submit_intent(Intent::Interact {
14331 entity_id: self.state.entity_id,
14332 target_id: npc_id,
14333 seq: self.seq,
14334 })
14335 .await?;
14336 }
14337 _ => {
14338 self.session
14339 .submit_intent(Intent::NpcTalkOpen {
14340 entity_id: self.state.entity_id,
14341 npc_id,
14342 seq: self.seq,
14343 })
14344 .await?;
14345 }
14346 }
14347 self.state.intents_sent += 1;
14348 Ok(())
14349 }
14350
14351 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
14352 let Some(chat) = self.state.npc_chat.clone() else {
14353 return Ok(());
14354 };
14355 let message = chat.input.trim().to_string();
14356 if message.is_empty() || chat.pending {
14357 return Ok(());
14358 }
14359 if let Some(c) = self.state.npc_chat.as_mut() {
14360 c.lines.push(format!("You: {message}"));
14361 c.input.clear();
14362 c.pending = true;
14363 }
14364 self.seq += 1;
14365 self.session
14366 .submit_intent(Intent::NpcTalkSay {
14367 entity_id: self.state.entity_id,
14368 npc_id: chat.npc_id,
14369 message,
14370 seq: self.seq,
14371 })
14372 .await?;
14373 self.state.intents_sent += 1;
14374 Ok(())
14375 }
14376
14377 pub async fn npc_talk_topic(&mut self, index: usize) -> anyhow::Result<()> {
14378 let topic = self
14379 .state
14380 .npc_chat
14381 .as_ref()
14382 .and_then(|c| c.suggested_topics.get(index))
14383 .cloned();
14384 let Some(topic) = topic else {
14385 return Ok(());
14386 };
14387 if let Some(c) = self.state.npc_chat.as_mut() {
14388 if c.pending {
14389 return Ok(());
14390 }
14391 c.input = topic;
14392 }
14393 self.npc_talk_send().await
14394 }
14395
14396 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
14397 let return_to_verbs = self.state.npc_verb_target.is_some();
14398 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
14399 self.state.show_npc_chat = false;
14400 if return_to_verbs {
14401 self.state.show_npc_verb_menu = true;
14402 }
14403 return Ok(());
14404 };
14405 self.seq += 1;
14406 self.session
14407 .submit_intent(Intent::NpcTalkClose {
14408 entity_id: self.state.entity_id,
14409 npc_id,
14410 seq: self.seq,
14411 })
14412 .await?;
14413 self.state.intents_sent += 1;
14414 self.state.show_npc_chat = false;
14415 self.state.npc_chat = None;
14416 if return_to_verbs {
14417 self.state.show_npc_verb_menu = true;
14418 }
14419 Ok(())
14420 }
14421
14422 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
14424 if self.state.show_quest_offer
14425 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
14426 {
14427 self.quest_offer_decline();
14428 return Ok(());
14429 }
14430 if self.state.show_npc_chat {
14431 return self.npc_talk_close().await;
14432 }
14433 if self.state.show_shop_menu {
14434 return self.back_from_shop_menu().await;
14435 }
14436 if self.state.bank_panel.is_some() {
14437 if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
14438 self.bank_transfer_back();
14439 return Ok(());
14440 }
14441 return self.close_bank_panel().await;
14442 }
14443 if self.state.storage_panel.is_some() {
14444 if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
14445 self.storage_ui_back();
14446 return Ok(());
14447 }
14448 return self.close_storage_panel().await;
14449 }
14450 if self.state.market_panel.is_some() {
14451 if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
14452 self.market_ui_back();
14453 return Ok(());
14454 }
14455 if self.state.market_buy_confirm.is_some() {
14456 self.state.market_buy_confirm = None;
14457 return Ok(());
14458 }
14459 return self.close_market_panel().await;
14460 }
14461 if self.state.show_npc_verb_menu {
14462 self.state.show_npc_verb_menu = false;
14463 self.state.npc_verb_target = None;
14464 }
14465 Ok(())
14466 }
14467
14468 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
14469 self.seq += 1;
14470 self.session
14471 .submit_intent(Intent::TestDamage {
14472 entity_id: self.state.entity_id,
14473 amount,
14474 seq: self.seq,
14475 })
14476 .await?;
14477 self.state.intents_sent += 1;
14478 Ok(())
14479 }
14480
14481 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
14482 self.cycle_combat_target_slot(1, reverse).await
14483 }
14484
14485 pub async fn cycle_combat_target_slot(
14486 &mut self,
14487 slot_index: u8,
14488 reverse: bool,
14489 ) -> anyhow::Result<()> {
14490 if !self.state.is_alive() {
14491 anyhow::bail!("you are dead");
14492 }
14493 let candidates = self.state.candidates_for_slot(slot_index);
14494 if candidates.is_empty() {
14495 anyhow::bail!("no targets nearby");
14496 }
14497 let current = self.state.target_for_slot(slot_index);
14498 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
14499 let next_idx = match idx {
14500 None => 0,
14501 Some(i) if reverse => {
14502 if i == 0 {
14503 candidates.len() - 1
14504 } else {
14505 i - 1
14506 }
14507 }
14508 Some(i) => (i + 1) % candidates.len(),
14509 };
14510 if idx == Some(next_idx) && candidates.len() == 1 {
14511 self.clear_combat_target_slot(slot_index).await?;
14512 return Ok(());
14513 }
14514 let (target_id, label) = candidates[next_idx].clone();
14515 self.set_combat_target_slot(slot_index, target_id, &label)
14516 .await
14517 }
14518
14519 pub async fn set_combat_target_slot(
14520 &mut self,
14521 slot_index: u8,
14522 target_id: EntityId,
14523 label: &str,
14524 ) -> anyhow::Result<()> {
14525 if !self.state.is_alive() {
14526 anyhow::bail!("you are dead");
14527 }
14528 self.seq += 1;
14529 self.session
14530 .submit_intent(Intent::SetTargetSlot {
14531 entity_id: self.state.entity_id,
14532 slot_index,
14533 target_id,
14534 seq: self.seq,
14535 })
14536 .await?;
14537 self.state.intents_sent += 1;
14538 if slot_index == 1 {
14539 self.state.combat_target = Some(target_id);
14540 self.state.combat_target_label = Some(label.to_string());
14541 }
14542 self.state
14543 .push_log(format!("Slot {slot_index} target: {label}"));
14544 Ok(())
14545 }
14546
14547 pub async fn set_combat_target(
14548 &mut self,
14549 target_id: EntityId,
14550 label: &str,
14551 ) -> anyhow::Result<()> {
14552 self.set_combat_target_slot(1, target_id, label).await
14553 }
14554
14555 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
14556 if slot_index == 1 && self.state.combat_target.is_none() {
14557 return Ok(());
14558 }
14559 self.seq += 1;
14560 self.session
14561 .submit_intent(Intent::ClearTargetSlot {
14562 entity_id: self.state.entity_id,
14563 slot_index,
14564 seq: self.seq,
14565 })
14566 .await?;
14567 if slot_index == 1 {
14568 self.state.combat_target = None;
14569 self.state.combat_target_label = None;
14570 }
14571 self.state.intents_sent += 1;
14572 self.state
14573 .push_log(format!("Slot {slot_index} target cleared"));
14574 Ok(())
14575 }
14576
14577 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
14578 self.clear_combat_target_slot(1).await
14579 }
14580
14581 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
14582 if !self.state.is_alive() {
14583 anyhow::bail!("you are dead");
14584 }
14585 self.seq += 1;
14586 self.session
14587 .submit_intent(Intent::AdvanceRotation {
14588 entity_id: self.state.entity_id,
14589 slot_index,
14590 seq: self.seq,
14591 })
14592 .await?;
14593 self.state.intents_sent += 1;
14594 Ok(())
14595 }
14596
14597 pub async fn assign_slot_preset(
14598 &mut self,
14599 slot_index: u8,
14600 preset_id: &str,
14601 ) -> anyhow::Result<()> {
14602 if !self.state.is_alive() {
14603 anyhow::bail!("you are dead");
14604 }
14605 self.seq += 1;
14606 self.session
14607 .submit_intent(Intent::AssignSlotPreset {
14608 entity_id: self.state.entity_id,
14609 slot_index,
14610 preset_id: preset_id.to_string(),
14611 seq: self.seq,
14612 })
14613 .await?;
14614 self.state.intents_sent += 1;
14615 if let Some(slot) = self
14616 .state
14617 .combat_slots
14618 .iter_mut()
14619 .find(|s| s.slot_index == slot_index)
14620 {
14621 slot.preset_id = Some(preset_id.to_string());
14622 if let Some(preset) = self
14623 .state
14624 .rotation_presets
14625 .iter()
14626 .find(|p| p.id == preset_id)
14627 {
14628 slot.preset_label = Some(preset.label.clone());
14629 slot.rotation = preset.abilities.clone();
14630 slot.rotation_index = 0;
14631 }
14632 }
14633 self.state
14634 .push_log(format!("T{slot_index} loadout → {preset_id}"));
14635 Ok(())
14636 }
14637
14638 pub async fn cast_ability(
14639 &mut self,
14640 ability_id: &str,
14641 target_id: Option<EntityId>,
14642 ) -> anyhow::Result<()> {
14643 if !self.state.is_alive() {
14644 anyhow::bail!("you are dead");
14645 }
14646 let allows_ground = self.state.ability_allows_ground(ability_id);
14647 let requires_ground = self.state.ability_requires_ground(ability_id);
14648 if requires_ground && self.state.ground_target.is_none() {
14649 anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
14650 }
14651 let (resolved_target_id, target_point) = if allows_ground {
14652 if let Some((x, y, z)) = self.state.ground_target {
14653 (
14654 target_id.unwrap_or(self.state.entity_id),
14655 Some(flatland_protocol::AimPoint { x, y, z }),
14656 )
14657 } else {
14658 (
14659 target_id
14660 .or_else(|| self.state.target_for_slot(2))
14661 .or_else(|| self.state.target_for_slot(1))
14662 .unwrap_or(self.state.entity_id),
14663 None,
14664 )
14665 }
14666 } else {
14667 (
14668 target_id
14669 .or_else(|| self.state.target_for_slot(2))
14670 .or_else(|| self.state.target_for_slot(1))
14671 .unwrap_or(self.state.entity_id),
14672 None,
14673 )
14674 };
14675 self.seq += 1;
14676 self.session
14677 .submit_intent(Intent::Cast {
14678 entity_id: self.state.entity_id,
14679 ability_id: ability_id.to_string(),
14680 target_id: resolved_target_id,
14681 target_point,
14682 seq: self.seq,
14683 })
14684 .await?;
14685 self.state.intents_sent += 1;
14686 match target_point {
14687 Some(point) => self.state.push_log(format!(
14688 "Cast {ability_id} → ({:.1}, {:.1})",
14689 point.x, point.y
14690 )),
14691 None => self
14692 .state
14693 .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
14694 }
14695 Ok(())
14696 }
14697
14698 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
14699 self.seq += 1;
14700 self.session
14701 .submit_intent(Intent::UpsertRotationPreset {
14702 entity_id: self.state.entity_id,
14703 preset: preset.clone(),
14704 seq: self.seq,
14705 })
14706 .await?;
14707 self.state.intents_sent += 1;
14708 if let Some(existing) = self
14709 .state
14710 .rotation_presets
14711 .iter_mut()
14712 .find(|p| p.id == preset.id)
14713 {
14714 *existing = preset.clone();
14715 } else {
14716 self.state.rotation_presets.push(preset.clone());
14717 }
14718 for slot in &mut self.state.combat_slots {
14719 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
14720 slot.preset_label = Some(preset.label.clone());
14721 slot.rotation = preset.abilities.clone();
14722 }
14723 }
14724 self.state
14725 .push_log(format!("Saved rotation: {}", preset.label));
14726 Ok(())
14727 }
14728
14729 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
14730 self.seq += 1;
14731 self.session
14732 .submit_intent(Intent::DeleteRotationPreset {
14733 entity_id: self.state.entity_id,
14734 preset_id: preset_id.to_string(),
14735 seq: self.seq,
14736 })
14737 .await?;
14738 self.state.intents_sent += 1;
14739 self.state.rotation_presets.retain(|p| p.id != preset_id);
14740 for slot in &mut self.state.combat_slots {
14741 if slot.preset_id.as_deref() == Some(preset_id) {
14742 slot.preset_id = None;
14743 slot.preset_label = None;
14744 slot.rotation.clear();
14745 slot.rotation_index = 0;
14746 }
14747 }
14748 self.state
14749 .push_log(format!("Deleted rotation: {preset_id}"));
14750 Ok(())
14751 }
14752
14753 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
14754 if !self.state.is_alive() {
14755 anyhow::bail!("you are dead");
14756 }
14757 let enabled = !self
14758 .state
14759 .combat_slots
14760 .iter()
14761 .find(|s| s.slot_index == slot_index)
14762 .map(|s| s.auto_enabled)
14763 .unwrap_or(false);
14764 self.seq += 1;
14765 self.session
14766 .submit_intent(Intent::SetAutoAttack {
14767 entity_id: self.state.entity_id,
14768 slot_index,
14769 enabled,
14770 seq: self.seq,
14771 })
14772 .await?;
14773 if slot_index == 1 {
14774 self.state.auto_attack = enabled;
14775 }
14776 self.state.intents_sent += 1;
14777 self.state.push_log(format!(
14778 "T{slot_index} auto {}",
14779 if enabled { "ON" } else { "OFF" }
14780 ));
14781 Ok(())
14782 }
14783
14784 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
14785 if !self.state.connected {
14786 anyhow::bail!("not connected");
14787 }
14788 if !self.state.is_alive() {
14789 anyhow::bail!("you are dead");
14790 }
14791 let (px, py) = self.state.player_position();
14792 if self
14793 .state
14794 .ground_drops
14795 .iter()
14796 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
14797 {
14798 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
14799 }
14800 self.seq += 1;
14801 self.session
14802 .submit_intent(Intent::Pickup {
14803 entity_id: self.state.entity_id,
14804 drop_id: None,
14805 seq: self.seq,
14806 })
14807 .await?;
14808 self.state.intents_sent += 1;
14809 self.state.push_audio(crate::social::AudioCue::LootPickup);
14810 Ok(())
14811 }
14812
14813 pub async fn dodge(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
14814 if !self.state.is_alive() {
14815 anyhow::bail!("you are dead");
14816 }
14817 self.seq += 1;
14819 self.session
14820 .submit_intent(Intent::Dodge {
14821 entity_id: self.state.entity_id,
14822 forward,
14823 strafe,
14824 seq: self.seq,
14825 })
14826 .await?;
14827 self.state.intents_sent += 1;
14828 self.state.push_log("Dodge!");
14829 self.state.push_audio(crate::social::AudioCue::CombatDodge);
14830 Ok(())
14831 }
14832
14833 pub async fn lunge(&mut self) -> anyhow::Result<()> {
14834 if !self.state.is_alive() {
14835 anyhow::bail!("you are dead");
14836 }
14837 let (forward, strafe) = self.last_move_axes();
14838 self.seq += 1;
14839 self.session
14840 .submit_intent(Intent::Lunge {
14841 entity_id: self.state.entity_id,
14842 forward,
14843 strafe,
14844 seq: self.seq,
14845 })
14846 .await?;
14847 self.state.intents_sent += 1;
14848 self.state.push_log("Lunge!");
14849 Ok(())
14850 }
14851
14852 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
14853 if !self.state.is_alive() {
14854 anyhow::bail!("you are dead");
14855 }
14856 self.seq += 1;
14857 self.session
14858 .submit_intent(Intent::DirectionalJump {
14859 entity_id: self.state.entity_id,
14860 forward,
14861 strafe,
14862 seq: self.seq,
14863 })
14864 .await?;
14865 self.state.intents_sent += 1;
14866 self.state.push_log("Jump!");
14867 Ok(())
14868 }
14869
14870 pub fn last_move_axes(&self) -> (f32, f32) {
14872 (self.last_move_forward, self.last_move_strafe)
14873 }
14874
14875 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
14876 if !self.state.is_alive() {
14877 anyhow::bail!("you are dead");
14878 }
14879 self.seq += 1;
14880 self.session
14881 .submit_intent(Intent::Block {
14882 entity_id: self.state.entity_id,
14883 enabled,
14884 seq: self.seq,
14885 })
14886 .await?;
14887 self.state.intents_sent += 1;
14888 if enabled {
14889 self.state.push_log("Blocking");
14890 self.state.push_audio(crate::social::AudioCue::CombatBlock);
14891 }
14892 Ok(())
14893 }
14894
14895 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
14896 if !self.state.is_alive() {
14897 anyhow::bail!("you are dead");
14898 }
14899 self.seq += 1;
14900 self.session
14901 .submit_intent(Intent::EquipMainhand {
14902 entity_id: self.state.entity_id,
14903 template_id,
14904 instance_id: None,
14905 seq: self.seq,
14906 })
14907 .await?;
14908 self.state.intents_sent += 1;
14909 Ok(())
14910 }
14911
14912 pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
14914 let idx = self.state.equip_menu_index;
14915 let slots = equip_paperdoll_rows(&self.state);
14916 let Some(row) = slots.get(idx) else {
14917 return Ok(());
14918 };
14919 match row {
14920 EquipPaperdollRow::Body { slot, filled } => {
14921 if *filled {
14922 self.equip_worn(*slot, None).await
14923 } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
14924 self.equip_worn(*slot, Some(inst)).await
14925 } else {
14926 self.state
14927 .push_log(format!("No item for {}", body_slot_label(*slot)));
14928 Ok(())
14929 }
14930 }
14931 EquipPaperdollRow::Mainhand { filled } => {
14932 if *filled {
14933 self.unequip_mainhand().await
14934 } else if let Some(tid) = first_inventory_weapon(&self.state) {
14935 self.equip_mainhand(Some(tid)).await
14936 } else {
14937 self.state.push_log("No weapon in inventory".to_string());
14938 Ok(())
14939 }
14940 }
14941 EquipPaperdollRow::Offhand { filled, locked } => {
14942 if *locked {
14943 self.state
14944 .push_log("Offhand locked — two-handed weapon equipped".to_string());
14945 Ok(())
14946 } else if *filled {
14947 self.unequip_offhand().await
14948 } else if let Some(tid) = first_inventory_offhand(&self.state) {
14949 self.equip_offhand(Some(tid)).await
14950 } else {
14951 self.state
14952 .push_log("No offhand item in inventory".to_string());
14953 Ok(())
14954 }
14955 }
14956 }
14957 }
14958
14959 pub async fn say(
14960 &mut self,
14961 channel: flatland_protocol::ChatChannel,
14962 text: &str,
14963 ) -> anyhow::Result<()> {
14964 self.say_to(channel, text, None).await
14965 }
14966
14967 pub async fn say_to(
14968 &mut self,
14969 channel: flatland_protocol::ChatChannel,
14970 text: &str,
14971 to_entity: Option<EntityId>,
14972 ) -> anyhow::Result<()> {
14973 self.seq += 1;
14974 self.session
14975 .submit_intent(Intent::Say {
14976 entity_id: self.state.entity_id,
14977 channel,
14978 text: text.to_string(),
14979 to_entity,
14980 seq: self.seq,
14981 })
14982 .await?;
14983 self.state.intents_sent += 1;
14984 Ok(())
14985 }
14986
14987 pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
14988 let Some(peer) = self.state.player_verbs.target_entity else {
14989 return Ok(());
14990 };
14991 let label = self.state.player_verbs.target_label.clone();
14992 let choice = crate::social::PlayerVerbState::options()
14993 .get(self.state.player_verbs.index)
14994 .copied()
14995 .unwrap_or("Whisper");
14996 self.state.player_verbs.close();
14997 match choice {
14998 "Trade" => {
14999 self.seq += 1;
15002 self.session
15003 .submit_intent(Intent::TradeRequest {
15004 entity_id: self.state.entity_id,
15005 peer_entity_id: peer,
15006 seq: self.seq,
15007 })
15008 .await?;
15009 self.state.intents_sent += 1;
15010 self.state.social_chat.push_system(format!(
15011 "Trade request sent to {label} — waiting for accept"
15012 ));
15013 }
15014 "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
15015 _ => self.state.social_chat.focus_nearby(),
15016 }
15017 Ok(())
15018 }
15019
15020 pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
15021 let Some(pending) = self.state.social_chat.pending_trade.take() else {
15022 return Ok(());
15023 };
15024 self.seq += 1;
15025 self.session
15026 .submit_intent(Intent::TradeRespond {
15027 entity_id: self.state.entity_id,
15028 peer_entity_id: pending.from_entity,
15029 accept,
15030 seq: self.seq,
15031 })
15032 .await?;
15033 self.state.intents_sent += 1;
15034 if accept {
15035 self.state
15036 .social_chat
15037 .push_system(format!("Accepted trade with {}", pending.from_name));
15038 } else {
15039 self.state
15040 .social_chat
15041 .push_system(format!("Declined trade with {}", pending.from_name));
15042 }
15043 Ok(())
15044 }
15045
15046 pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
15047 let text = self.state.social_chat.buffer.trim().to_string();
15048 if text.is_empty() {
15049 return Ok(());
15050 }
15051 self.state.social_chat.buffer.clear();
15052 if crate::social::is_chat_slash_line(&text) {
15053 match crate::social::parse_chat_slash(&text) {
15054 Some(cmd) => return self.apply_chat_slash(cmd).await,
15055 None => {
15056 self.state.social_chat.push_system(format!(
15057 "Unknown command — {}",
15058 crate::social::chat_slash_help_text()
15059 ));
15060 return Ok(());
15061 }
15062 }
15063 }
15064 let thread = self.state.social_chat.thread;
15065 let channel = thread.channel();
15066 let to = thread.to_entity();
15067 if let Some(peer) = to {
15068 let label = self.state.social_chat.peer_label.clone();
15069 self.state
15070 .social_chat
15071 .remember_whisper_peer(peer, &label, channel);
15072 }
15073 self.say_to(channel, &text, to).await
15074 }
15075
15076 async fn apply_chat_slash(
15077 &mut self,
15078 cmd: crate::social::ChatSlashCommand,
15079 ) -> anyhow::Result<()> {
15080 use crate::social::{chat_slash_help_text, ChatSlashCommand};
15081 match cmd {
15082 ChatSlashCommand::Help => {
15083 self.state
15084 .social_chat
15085 .push_system(chat_slash_help_text().to_string());
15086 Ok(())
15087 }
15088 ChatSlashCommand::Nearby { message } => {
15089 self.state.social_chat.focus_nearby();
15090 self.state
15091 .social_chat
15092 .push_system("Nearby speech — everyone close can hear");
15093 if let Some(msg) = message {
15094 self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
15095 .await
15096 } else {
15097 Ok(())
15098 }
15099 }
15100 ChatSlashCommand::Reply { message } => {
15101 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
15102 self.state
15103 .social_chat
15104 .push_system("No one to reply to — wait for a whisper, or /whisper Name");
15105 return Ok(());
15106 };
15107 let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
15108 self.state
15109 .social_chat
15110 .set_whisper_thread(peer.entity_id, &peer.label, stone);
15111 self.state.social_chat.push_system(format!(
15112 "Replying to {} — type and Enter · /nearby",
15113 peer.label
15114 ));
15115 if let Some(msg) = message {
15116 self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
15117 } else {
15118 Ok(())
15119 }
15120 }
15121 ChatSlashCommand::Whisper { name, message } => {
15122 let (peer_id, label, stone) = if let Some(name) = name {
15123 match self.resolve_whisper_target(&name) {
15124 Ok(t) => t,
15125 Err(err) => {
15126 self.state.social_chat.push_system(err);
15127 return Ok(());
15128 }
15129 }
15130 } else {
15131 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
15132 self.state.social_chat.push_system(
15133 "Usage: /whisper Name [message] · or /reply after someone whispers you",
15134 );
15135 return Ok(());
15136 };
15137 (
15138 peer.entity_id,
15139 peer.label,
15140 peer.channel == flatland_protocol::ChatChannel::WhisperStone,
15141 )
15142 };
15143 self.state
15144 .social_chat
15145 .set_whisper_thread(peer_id, &label, stone);
15146 let channel = if stone {
15147 flatland_protocol::ChatChannel::WhisperStone
15148 } else {
15149 flatland_protocol::ChatChannel::Whisper
15150 };
15151 if let Some(msg) = message {
15152 self.state
15153 .social_chat
15154 .push_system(format!("Whisper → {label}"));
15155 self.say_to(channel, &msg, Some(peer_id)).await
15156 } else {
15157 self.state.social_chat.push_system(format!(
15158 "Whispering {label} — type and Enter · Esc / /nearby cancels"
15159 ));
15160 Ok(())
15161 }
15162 }
15163 }
15164 }
15165
15166 fn resolve_whisper_target(&self, name: &str) -> Result<(EntityId, String, bool), String> {
15168 let needle = name.trim().to_ascii_lowercase();
15169 if needle.is_empty() {
15170 return Err("Usage: /whisper Name [message]".into());
15171 }
15172 let mut candidates: Vec<(EntityId, String)> = self
15173 .state
15174 .entities
15175 .iter()
15176 .filter(|e| e.id != self.state.entity_id)
15177 .filter(|e| !e.label.trim().is_empty())
15178 .filter(|e| e.vitals.is_some())
15179 .filter(|e| !self.state.npcs.iter().any(|n| n.id == e.id.to_string()))
15180 .filter(|e| !self.state.hired_workers.iter().any(|w| w.entity_id == e.id))
15181 .map(|e| (e.id, e.label.clone()))
15182 .collect();
15183
15184 if let Some(last) = &self.state.social_chat.last_whisper_peer {
15186 if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
15187 candidates.push((last.entity_id, last.label.clone()));
15188 }
15189 }
15190
15191 let exact: Vec<_> = candidates
15192 .iter()
15193 .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
15194 .cloned()
15195 .collect();
15196 let pool = if exact.len() == 1 {
15197 exact
15198 } else if exact.len() > 1 {
15199 return Err(format!(
15200 "Several players named '{name}' nearby — move closer and try again"
15201 ));
15202 } else {
15203 let starts: Vec<_> = candidates
15204 .iter()
15205 .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
15206 .cloned()
15207 .collect();
15208 if starts.len() == 1 {
15209 starts
15210 } else if starts.len() > 1 {
15211 let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
15212 return Err(format!(
15213 "Ambiguous name '{name}' — matches: {}",
15214 names.join(", ")
15215 ));
15216 } else {
15217 let contains: Vec<_> = candidates
15218 .iter()
15219 .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
15220 .cloned()
15221 .collect();
15222 if contains.len() == 1 {
15223 contains
15224 } else if contains.is_empty() {
15225 return Err(format!(
15226 "No player matching '{name}' in range — get closer or check the spelling"
15227 ));
15228 } else {
15229 let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
15230 return Err(format!(
15231 "Ambiguous name '{name}' — matches: {}",
15232 names.join(", ")
15233 ));
15234 }
15235 }
15236 };
15237
15238 let (id, label) = pool.into_iter().next().unwrap();
15239 let stone = self
15240 .state
15241 .social_chat
15242 .last_whisper_peer
15243 .as_ref()
15244 .is_some_and(|p| {
15245 p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone
15246 });
15247 Ok((id, label, stone))
15248 }
15249
15250 pub async fn trade_present_selected(
15251 &mut self,
15252 item_instance_id: uuid::Uuid,
15253 ) -> anyhow::Result<()> {
15254 self.trade_present_quantity(item_instance_id, None).await
15255 }
15256
15257 pub async fn trade_present_quantity(
15258 &mut self,
15259 item_instance_id: uuid::Uuid,
15260 quantity: Option<u32>,
15261 ) -> anyhow::Result<()> {
15262 self.seq += 1;
15263 self.session
15264 .submit_intent(Intent::TradePresent {
15265 entity_id: self.state.entity_id,
15266 item_instance_id,
15267 quantity,
15268 seq: self.seq,
15269 })
15270 .await?;
15271 self.state.intents_sent += 1;
15272 self.state.trade_ui.qty_entry = None;
15273 self.state.trade_ui.picking_inventory = false;
15274 Ok(())
15275 }
15276
15277 pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
15279 if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
15280 let qty = self.state.trade_ui.present_quantity();
15281 return self
15282 .trade_present_quantity(entry.item_instance_id, qty)
15283 .await;
15284 }
15285 if !self.state.trade_ui.picking_inventory {
15286 return Ok(());
15287 }
15288 let stacks = self.state.trade_presentable_stacks();
15289 let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
15290 return Ok(());
15291 };
15292 let Some(id) = stack.item_instance_id else {
15293 return Ok(());
15294 };
15295 let label = stack
15296 .display_name
15297 .clone()
15298 .unwrap_or_else(|| stack.template_id.clone());
15299 if stack.quantity <= 1 {
15300 self.trade_present_quantity(id, Some(1)).await
15301 } else {
15302 self.state
15303 .trade_ui
15304 .begin_qty_entry(id, label, stack.quantity);
15305 Ok(())
15306 }
15307 }
15308
15309 pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
15310 self.seq += 1;
15311 self.session
15312 .submit_intent(Intent::TradeSetReady {
15313 entity_id: self.state.entity_id,
15314 ready,
15315 seq: self.seq,
15316 })
15317 .await?;
15318 self.state.intents_sent += 1;
15319 Ok(())
15320 }
15321
15322 pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
15323 self.seq += 1;
15324 self.session
15325 .submit_intent(Intent::TradeCancel {
15326 entity_id: self.state.entity_id,
15327 seq: self.seq,
15328 })
15329 .await?;
15330 self.state.intents_sent += 1;
15331 self.state.trade_ui.close();
15332 Ok(())
15333 }
15334
15335 pub async fn destroy_whisper_stone(
15336 &mut self,
15337 item_instance_id: uuid::Uuid,
15338 ) -> anyhow::Result<()> {
15339 self.seq += 1;
15340 self.session
15341 .submit_intent(Intent::DestroyWhisperStone {
15342 entity_id: self.state.entity_id,
15343 item_instance_id,
15344 seq: self.seq,
15345 })
15346 .await?;
15347 self.state.intents_sent += 1;
15348 Ok(())
15349 }
15350
15351 pub async fn stop(&mut self) -> anyhow::Result<()> {
15352 self.seq += 1;
15353 self.session
15354 .submit_intent(Intent::Stop {
15355 entity_id: self.state.entity_id,
15356 seq: self.seq,
15357 })
15358 .await?;
15359 self.state.intents_sent += 1;
15360 Ok(())
15361 }
15362
15363 pub fn disconnect(&self) {
15364 self.session.disconnect();
15365 }
15366}
15367
15368fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
15369 let dx = ax - bx;
15370 let dy = ay - by;
15371 (dx * dx + dy * dy).sqrt()
15372}
15373
15374#[cfg(test)]
15375mod tests {
15376 use std::collections::BTreeMap;
15377
15378 use super::*;
15379 use flatland_protocol::{
15380 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
15381 };
15382
15383 fn sample_state() -> GameState {
15384 let mut state = GameState {
15385 session_id: 1,
15386 entity_id: 1,
15387 character_id: None,
15388 tick: 0,
15389 chunk_rev: 0,
15390 content_rev: 0,
15391 publish_rev: 0,
15392 entities: vec![EntityState {
15393 id: 1,
15394 label: "You".into(),
15395 transform: Transform {
15396 position: WorldCoord::surface(128.0, 128.0),
15397 yaw: 0.0,
15398 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
15399 },
15400 vitals: None,
15401 attributes: None,
15402 skills: None,
15403 inside_building: None,
15404 tile_id: None,
15405 paperdoll_ref: None,
15406 draw_scale: 1.0,
15407 presentation_state: None,
15408 sprite_mode: None,
15409 progression_xp: None,
15410 combat_cues: vec![],
15411 statuses: vec![],
15412 }],
15413 player: None,
15414 resource_nodes: vec![ResourceNodeView {
15415 id: "oak-1".into(),
15416 label: "Oak".into(),
15417 x: 130.0,
15418 y: 128.0,
15419 z: 0.0,
15420 item_template: "oak_log".into(),
15421 state: ResourceNodeState::Available,
15422 blocking: true,
15423 blocking_radius_m: 0.8,
15424 harvest_off: false,
15425 tile_id: None,
15426 yaw: 0.0,
15427 pitch: 0.0,
15428 roll: 0.0,
15429 draw_scale: 1.0,
15430 sprite_mode: None,
15431 growth_progress: None,
15432 presentation_state: None,
15433 channel_start_tick: None,
15434 channel_end_tick: None,
15435 harvest_drop_templates: vec![],
15436 }],
15437 ground_drops: vec![],
15438 placed_containers: vec![],
15439 buildings: vec![BuildingView {
15440 id: "broker-hut".into(),
15441 label: "Broker".into(),
15442 x: 148.0,
15443 y: 118.0,
15444 width_m: 8.0,
15445 depth_m: 6.0,
15446 interior_blueprint: Some("broker_hut".into()),
15447 tags: vec![],
15448 market_boundary_zone_ids: vec![],
15449 market_max_volume: None,
15450 wall_set: None,
15451 roof_set: None,
15452 }],
15453 doors: vec![flatland_protocol::DoorView {
15454 id: "door-1".into(),
15455 building_id: "broker-hut".into(),
15456 x: 148.0,
15457 y: 118.0,
15458 open: false,
15459 portal: Some("front".into()),
15460 locked: false,
15461 accessible: true,
15462 lock_id: None,
15463 }],
15464 interior_map: None,
15465 npcs: vec![],
15466 blueprints: vec![],
15467 building_materials: vec![],
15468 world_x0: 0.0,
15469 world_y0: 0.0,
15470 world_width_m: 256.0,
15471 world_height_m: 256.0,
15472 terrain_zones: Vec::new(),
15473 z_platforms: Vec::new(),
15474 z_transitions: Vec::new(),
15475 z_bands_outdoor_backup: None,
15476 world_clock: flatland_protocol::WorldClock::default(),
15477 inventory: std::collections::HashMap::new(),
15478 inventory_hints: std::collections::HashMap::new(),
15479 logs: VecDeque::new(),
15480 intents_sent: 0,
15481 ticks_received: 0,
15482 connected: true,
15483 disconnect_reason: None,
15484 show_stats: false,
15485 hud_log_hidden: false,
15486 show_equip_menu: false,
15487 equip_menu_index: 0,
15488 show_craft_menu: false,
15489 show_plot_build_menu: false,
15490 plot_build_focus_wall: true,
15491 plot_build_wall_index: 0,
15492 plot_build_roof_index: 0,
15493 craft_menu_index: 0,
15494 craft_batch_quantity: 1,
15495 show_shop_menu: false,
15496 shop_catalog: None,
15497 bank_panel: None,
15498 bank_menu_index: 0,
15499 bank_ui_mode: BankUiMode::Menu,
15500 storage_panel: None,
15501 market_panel: None,
15502 market_menu_index: 0,
15503 market_filter: String::new(),
15504 market_filter_focused: false,
15505 market_category_filter: None,
15506 market_buy_confirm: None,
15507 market_ui_mode: MarketUiMode::Browse,
15508 storage_menu_index: 0,
15509 storage_ui_mode: StorageUiMode::Menu,
15510 shop_tab: ShopTab::default(),
15511 shop_menu_index: 0,
15512 shop_quantity: 1,
15513 shop_trade_log: VecDeque::new(),
15514 show_npc_verb_menu: false,
15515 npc_verb_target: None,
15516 npc_verb_index: 0,
15517 player_verbs: crate::social::PlayerVerbState::default(),
15518 social_chat: crate::social::SocialChatState::default(),
15519 trade_ui: crate::social::TradeUiState::default(),
15520 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
15521 show_npc_chat: false,
15522 npc_chat: None,
15523 show_inventory_menu: false,
15524 inventory_menu_index: 0,
15525 inventory_tab: InventoryTab::OnPerson,
15526 inventory_filter: String::new(),
15527 inventory_filter_focused: false,
15528 show_move_picker: false,
15529 show_rename_prompt: false,
15530 rename_plot_id: None,
15531 highlighted_plot_id: None,
15532 show_worker_rename: false,
15533 rename_buffer: String::new(),
15534 move_picker_index: 0,
15535 move_picker: None,
15536 show_grant_picker: false,
15537 grant_picker_index: 0,
15538 grant_picker: None,
15539 show_destroy_picker: false,
15540 destroy_confirm_pending: false,
15541 destroy_picker: None,
15542 combat_target: None,
15543 combat_target_label: None,
15544 ground_target: None,
15545 combat_fx: Vec::new(),
15546 ground_hazards: Vec::new(),
15547 property_zones: Vec::new(),
15548 tax_zones: Vec::new(),
15549 growth_zones: Vec::new(),
15550 biome_zones: Vec::new(),
15551 terrain_kind_nav: Vec::new(),
15552 property_plots: Vec::new(),
15553 property_plot_settings: None,
15554 claim_mode: None,
15555 relocate_mode: None,
15556 sell_plot_confirm: None,
15557 sell_plot_armed_at: None,
15558 show_plant_menu: false,
15559 plant_menu_index: 0,
15560 show_farm_access: false,
15561 farm_access_name_draft: String::new(),
15562 farm_access_discount_bps: 0,
15563 farm_access_index: 0,
15564 plant_quantity: 1,
15565 in_combat: false,
15566 auto_attack: true,
15567 combat_has_los: false,
15568 attack_cd_ticks: 0,
15569 gcd_ticks: 0,
15570 weapon_ability_id: "unarmed".into(),
15571 mainhand_template_id: None,
15572 mainhand_label: None,
15573 mainhand_instance_id: None,
15574 offhand_template_id: None,
15575 offhand_label: None,
15576 offhand_instance_id: None,
15577 mainhand_hand_slots: 1,
15578 defense: None,
15579 worn: BTreeMap::new(),
15580 carry_mass: 0.0,
15581 carry_mass_max: 0.0,
15582 encumbrance: flatland_protocol::EncumbranceState::Light,
15583 inventory_stacks: Vec::new(),
15584 keychain_stacks: Vec::new(),
15585 whisper_pouch_stacks: Vec::new(),
15586 combat_target_detail: None,
15587 statuses: Vec::new(),
15588 cast_progress: None,
15589 timed_channel: None,
15590 plot_build_offer: None,
15591 ability_cooldowns: Vec::new(),
15592 blocking_active: false,
15593 max_target_slots: 1,
15594 combat_slots: Vec::new(),
15595 rotation_presets: Vec::new(),
15596 known_abilities: Vec::new(),
15597 ability_meta: std::collections::HashMap::new(),
15598 ability_mastery: std::collections::HashMap::new(),
15599 hotbar: vec![None; 9],
15600 max_abilities_per_rotation: 0,
15601 show_loadout_menu: false,
15602 show_keychain_menu: false,
15603 keychain_menu_index: 0,
15604 show_rotation_editor: false,
15605 loadout_menu_index: 0,
15606 loadout_hotbar_slot: 1,
15607 loadout_ability_index: 0,
15608 loadout_focus_presets: false,
15609 rotation_editor: RotationEditorState::default(),
15610 harvest_in_progress: false,
15611 harvest_started_at: None,
15612 pending_craft_ack: None,
15613 pending_worker_job_ack: None,
15614 attending_worker_instance_id: None,
15615 quest_log: Vec::new(),
15616 interactables: Vec::new(),
15617 ledger: None,
15618 career: None,
15619 character_sheet_tab: CharacterSheetTab::Character,
15620 ledger_period: LedgerPeriod::Day,
15621 show_quest_offer: false,
15622 pending_quest_offer: None,
15623 show_quest_menu: false,
15624 quest_menu_index: 0,
15625 quest_withdraw_confirm: false,
15626 hired_workers: Vec::new(),
15627 show_workers_menu: false,
15628 workers_menu_index: 0,
15629 worker_dismiss_confirmation: None,
15630 workers_menu_compact: false,
15631 worker_step_display: BTreeMap::new(),
15632 worker_error_display: BTreeMap::new(),
15633 worker_health_ring_until: BTreeMap::new(),
15634 show_worker_give_picker: false,
15635 worker_give_picker_index: 0,
15636 worker_give_picker: None,
15637 show_worker_give_target_picker: false,
15638 worker_give_target_picker_index: 0,
15639 worker_give_target_picker: None,
15640 show_worker_take_picker: false,
15641 worker_take_picker_index: 0,
15642 worker_take_picker: None,
15643 show_worker_teach_picker: false,
15644 worker_teach_picker_index: 0,
15645 worker_teach_picker: None,
15646 worker_route_editor: None,
15647 progression_curve: None,
15648 };
15649 state.player = state.entities.first().cloned();
15650 state
15651 }
15652
15653 #[test]
15654 fn whisper_cancels_when_peer_walks_out_of_range() {
15655 let mut state = sample_state();
15656 state.player = state.entities.first().cloned();
15657 let mut peer = state.entities[0].clone();
15658 peer.id = 2;
15659 peer.label = "Ada".into();
15660 peer.transform.position = WorldCoord::surface(129.0, 128.0); state.entities.push(peer.clone());
15662 state.social_chat.focus_whisper(2, "Ada");
15663 state.refresh_whisper_range();
15664 assert!(matches!(
15665 state.social_chat.thread,
15666 crate::social::ChatThreadKind::Whisper { peer: 2 }
15667 ));
15668
15669 peer.transform.position = WorldCoord::surface(132.0, 128.0); state.entities[1] = peer;
15671 state.refresh_whisper_range();
15672 assert_eq!(
15673 state.social_chat.thread,
15674 crate::social::ChatThreadKind::Nearby
15675 );
15676 assert!(!state.social_chat.input_focused);
15677 }
15678
15679 #[test]
15680 fn probe_use_world_hired_worker_manage() {
15681 let mut state = sample_state();
15682 state
15683 .hired_workers
15684 .push(flatland_protocol::HiredWorkerView {
15685 instance_id: "worker-1".into(),
15686 entity_id: 42,
15687 def_id: "worker_laborer".into(),
15688 label: "Sam".into(),
15689 x: 129.0,
15690 y: 128.0,
15691 z: 0.0,
15692 mode: flatland_protocol::WorkerModeView::JobLoop,
15693 state: flatland_protocol::WorkerStateView::Working,
15694 step_label: "cultivate".into(),
15695 vitals: flatland_protocol::WorkerVitalsSummary {
15696 health_pct: 100.0,
15697 stamina_pct: 100.0,
15698 },
15699 carry_pct: 0.0,
15700 last_error: None,
15701 wage_copper_per_interval: 1,
15702 effective_wage_copper: 1,
15703 wage_meters_walked: 0.0,
15704 lodging_container_id: None,
15705 route: None,
15706 route_stop_index: None,
15707 known_blueprint_ids: Vec::new(),
15708 level: 1,
15709 worker_xp: 0.0,
15710 inventory: Vec::new(),
15711 equipment: flatland_protocol::WorkerEquipmentView::default(),
15712 issue_hint: None,
15713 });
15714 let probe = state.probe_use_world();
15715 let primary = probe.primary.expect("primary");
15716 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
15717 assert_eq!(primary.id, "worker-1");
15718 assert!(primary.hint_line().contains("Manage"));
15719 assert!(primary.hint_line().contains("Sam"));
15720 assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
15721 }
15722
15723 #[test]
15724 fn market_clerk_verb_options_include_market() {
15725 let mut state = sample_state();
15726 state.npcs.push(flatland_protocol::NpcView {
15727 id: "mira_market".into(),
15728 label: "Mira".into(),
15729 role: "market_clerk".into(),
15730 x: 129.0,
15731 y: 128.0,
15732 building_id: Some("town_market".into()),
15733 entity_id: None,
15734 life_state: None,
15735 hp_pct: None,
15736 can_trade: false,
15737 buy_templates: vec![],
15738 tile_id: None,
15739 behavior_state: None,
15740 presentation_state: None,
15741 sprite_mode: None,
15742 paperdoll_ref: None,
15743 draw_scale: 1.0,
15744 yaw: None,
15745 perception_fov_deg: None,
15746 perception_sight_m: None,
15747 perception_hear_m: None,
15748 });
15749 state.npc_verb_target = Some("mira_market".into());
15750 assert_eq!(state.npc_verb_options(), vec!["Market", "Talk"]);
15751 }
15752
15753 #[test]
15754 fn market_list_excludes_currency_stacks() {
15755 let mut state = sample_state();
15756 state.inventory_stacks = vec![
15757 flatland_protocol::ItemStack {
15758 template_id: "copper_coin".into(),
15759 quantity: 50,
15760 item_instance_id: Some(uuid::Uuid::from_u128(10)),
15761 display_name: Some("Copper Coin".into()),
15762 ..Default::default()
15763 },
15764 flatland_protocol::ItemStack {
15765 template_id: "oak_log".into(),
15766 quantity: 2,
15767 item_instance_id: Some(uuid::Uuid::from_u128(11)),
15768 display_name: Some("Oak Log".into()),
15769 ..Default::default()
15770 },
15771 flatland_protocol::ItemStack {
15772 template_id: "whisper_stone".into(),
15773 quantity: 1,
15774 item_instance_id: Some(uuid::Uuid::from_u128(12)),
15775 display_name: Some("Whisper Stone".into()),
15776 category: Some("quest".into()),
15777 listable: Some(false),
15778 ..Default::default()
15779 },
15780 ];
15781 let opts = state.market_list_item_options(&MarketListSourceKind::Person);
15782 assert_eq!(opts.len(), 1);
15783 assert!(opts[0].label.contains("Oak"));
15784 }
15785
15786 #[test]
15787 fn market_browse_filters_by_category_and_search() {
15788 let mut state = sample_state();
15789 state.market_panel = Some(flatland_protocol::MarketPanel {
15790 npc_id: "mira_market".into(),
15791 npc_label: "Mira".into(),
15792 building_id: "town_market".into(),
15793 building_label: "Town Market".into(),
15794 used_volume: 0.0,
15795 max_volume: 100.0,
15796 listings: vec![
15797 flatland_protocol::MarketListingView {
15798 listing_id: uuid::Uuid::from_u128(1),
15799 seller_character_id: uuid::Uuid::from_u128(2),
15800 seller_label: "Ada".into(),
15801 hall_building_id: "town_market".into(),
15802 hall_label: "Town Market".into(),
15803 template_id: "oak_log".into(),
15804 display_name: "Oak Log".into(),
15805 category: "resource".into(),
15806 quantity: 3,
15807 unit_price_copper: 10,
15808 line_total_copper: 30,
15809 npc_price: false,
15810 npc_dump_unit_copper: None,
15811 mine: false,
15812 },
15813 flatland_protocol::MarketListingView {
15814 listing_id: uuid::Uuid::from_u128(3),
15815 seller_character_id: uuid::Uuid::from_u128(2),
15816 seller_label: "Ada".into(),
15817 hall_building_id: "town_market".into(),
15818 hall_label: "Town Market".into(),
15819 template_id: "short_sword".into(),
15820 display_name: "Short Sword".into(),
15821 category: "weapon".into(),
15822 quantity: 1,
15823 unit_price_copper: 100,
15824 line_total_copper: 100,
15825 npc_price: false,
15826 npc_dump_unit_copper: None,
15827 mine: false,
15828 },
15829 ],
15830 tax_bps: 0,
15831 tax_flat_copper: 0,
15832 list_vaults: vec![],
15833 });
15834 assert_eq!(state.market_filtered_listing_indices().len(), 2);
15835 state.market_category_filter = Some("Weapons");
15836 let weapons = state.market_filtered_listing_indices();
15837 assert_eq!(weapons.len(), 1);
15838 assert_eq!(
15839 state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
15840 "Short Sword"
15841 );
15842 state.market_category_filter = None;
15843 state.market_filter = "oak".into();
15844 let oak = state.market_filtered_listing_indices();
15845 assert_eq!(oak.len(), 1);
15846 assert_eq!(
15847 state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
15848 "Oak Log"
15849 );
15850 }
15851
15852 #[test]
15853 fn market_list_source_includes_person_and_vaults() {
15854 let mut state = sample_state();
15855 let item_id = uuid::Uuid::from_u128(1);
15856 state.inventory_stacks = vec![flatland_protocol::ItemStack {
15857 template_id: "oak_log".into(),
15858 quantity: 2,
15859 item_instance_id: Some(item_id),
15860 display_name: Some("Oak Log".into()),
15861 ..Default::default()
15862 }];
15863 state.market_panel = Some(flatland_protocol::MarketPanel {
15864 npc_id: "mira_market".into(),
15865 npc_label: "Mira".into(),
15866 building_id: "town_market".into(),
15867 building_label: "Town Market".into(),
15868 used_volume: 0.0,
15869 max_volume: 100.0,
15870 listings: vec![],
15871 tax_bps: 0,
15872 tax_flat_copper: 0,
15873 list_vaults: vec![flatland_protocol::MarketListVault {
15874 building_id: "town_storage".into(),
15875 building_label: "Town Storage".into(),
15876 contents: vec![flatland_protocol::ItemStack {
15877 template_id: "lumber".into(),
15878 quantity: 1,
15879 item_instance_id: Some(uuid::Uuid::from_u128(2)),
15880 display_name: Some("Lumber".into()),
15881 ..Default::default()
15882 }],
15883 }],
15884 });
15885 let sources = state.market_list_source_options();
15886 assert_eq!(sources.len(), 2);
15887 assert!(matches!(sources[0].0, MarketListSourceKind::Person));
15888 assert!(matches!(
15889 sources[1].0,
15890 MarketListSourceKind::TownStorage { .. }
15891 ));
15892 assert!(sources[1].1.contains("Town Storage"));
15893 }
15894
15895 #[test]
15896 fn npc_market_dump_estimate_from_town_storage_vault() {
15897 let mut state = sample_state();
15898 state.market_panel = Some(flatland_protocol::MarketPanel {
15899 npc_id: "mira_market".into(),
15900 npc_label: "Mira".into(),
15901 building_id: "town_market".into(),
15902 building_label: "Town Market".into(),
15903 used_volume: 0.0,
15904 max_volume: 100.0,
15905 listings: vec![],
15906 tax_bps: 0,
15907 tax_flat_copper: 0,
15908 list_vaults: vec![flatland_protocol::MarketListVault {
15909 building_id: "town_storage".into(),
15910 building_label: "Town Storage".into(),
15911 contents: vec![flatland_protocol::ItemStack {
15912 template_id: "lumber".into(),
15913 quantity: 3,
15914 item_instance_id: Some(uuid::Uuid::from_u128(2)),
15915 display_name: Some("Lumber".into()),
15916 base_value_copper: Some(20),
15917 ..Default::default()
15918 }],
15919 }],
15920 });
15921 assert_eq!(
15922 state.npc_market_dump_unit_estimate("lumber"),
15923 Some(9),
15924 "vault stack base_value should enable NPC price estimate"
15925 );
15926 }
15927
15928 #[test]
15929 fn probe_use_world_npc_beats_nearby_loot() {
15930 let mut state = sample_state();
15931 state.npcs.push(flatland_protocol::NpcView {
15932 id: "ada".into(),
15933 label: "Ada".into(),
15934 role: "broker".into(),
15935 x: 129.0,
15936 y: 128.0,
15937 building_id: None,
15938 entity_id: None,
15939 life_state: None,
15940 hp_pct: None,
15941 can_trade: true,
15942 buy_templates: vec!["lumber".into()],
15943 tile_id: None,
15944 behavior_state: None,
15945 presentation_state: None,
15946 sprite_mode: None,
15947 paperdoll_ref: None,
15948 draw_scale: 1.0,
15949 yaw: None,
15950 perception_fov_deg: None,
15951 perception_sight_m: None,
15952 perception_hear_m: None,
15953 });
15954 state.ground_drops.push(flatland_protocol::GroundDropView {
15955 id: "d1".into(),
15956 template_id: "lumber".into(),
15957 quantity: 1,
15958 x: 128.5,
15959 y: 128.0,
15960 z: 0.0,
15961 tile_id: None,
15962 display_name: None,
15963 yaw: 0.0,
15964 pitch: 0.0,
15965 roll: 0.0,
15966 draw_scale: 1.0,
15967 });
15968 let probe = state.probe_use_world();
15969 let primary = probe.primary.expect("primary");
15970 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
15971 assert_eq!(primary.id, "ada");
15972 }
15973
15974 #[test]
15975 fn probe_use_world_harvest_when_in_range() {
15976 let state = sample_state(); let probe = state.probe_use_world();
15978 assert!(
15979 probe.primary.is_none(),
15980 "oak is 2m away, out of harvest range"
15981 );
15982 assert!(probe
15983 .candidates
15984 .iter()
15985 .any(|c| c.kind == crate::UseWorldKind::Harvest));
15986
15987 let mut state = sample_state();
15988 state.resource_nodes[0].x = 129.0;
15989 let probe = state.probe_use_world();
15990 let primary = probe.primary.expect("primary");
15991 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
15992 }
15993
15994 #[test]
15995 fn probe_use_world_door_uses_building_label() {
15996 let mut state = sample_state();
15997 state.doors[0].x = 129.0;
15998 state.doors[0].y = 128.0;
15999 let probe = state.probe_use_world();
16000 let primary = probe.primary.expect("primary");
16001 assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
16002 assert_eq!(primary.label, "Broker");
16003 assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
16004 }
16005
16006 #[test]
16007 fn empty_entity_tick_preserves_welcome_snapshot() {
16008 let mut state = sample_state();
16009 state.inventory.insert("carrot".into(), 3);
16010 let delta = TickDelta {
16011 tick: 1,
16012 entities: vec![],
16013 resource_nodes: vec![],
16014 ground_drops: vec![],
16015 placed_containers: vec![],
16016 buildings: vec![],
16017 doors: vec![],
16018 interior_map: None,
16019 npcs: vec![],
16020 inventory: vec![],
16021 blueprints: vec![],
16022 building_materials: vec![],
16023 world_clock: flatland_protocol::WorldClock::default(),
16024 combat: None,
16025 quest_log: vec![],
16026 hired_workers: Vec::new(),
16027 interactables: vec![],
16028 ledger: None,
16029 career: None,
16030 combat_fx: Vec::new(),
16031 ground_hazards: Vec::new(),
16032 property_plots: Vec::new(),
16033 terrain_overlays: Vec::new(),
16034 };
16035
16036 state.apply_tick_fields(&delta, 1);
16037
16038 assert_eq!(state.entities.len(), 1);
16039 assert!(state.player.is_some());
16040 assert_eq!(state.inventory.get("carrot"), Some(&3));
16041 assert_eq!(state.resource_nodes.len(), 1);
16042 }
16043
16044 #[test]
16045 fn tick_preserves_world_layers_when_delta_omits_them() {
16046 let mut state = sample_state();
16047 let delta = TickDelta {
16048 tick: 1,
16049 entities: state.entities.clone(),
16050 resource_nodes: vec![],
16051 ground_drops: vec![],
16052 placed_containers: vec![],
16053 buildings: vec![],
16054 doors: vec![],
16055 interior_map: None,
16056 npcs: vec![],
16057 inventory: vec![],
16058 blueprints: vec![],
16059 building_materials: vec![],
16060 world_clock: flatland_protocol::WorldClock::default(),
16061 combat: None,
16062 quest_log: vec![],
16063 hired_workers: Vec::new(),
16064 interactables: vec![],
16065 ledger: None,
16066 career: None,
16067 combat_fx: Vec::new(),
16068 ground_hazards: Vec::new(),
16069 property_plots: Vec::new(),
16070 terrain_overlays: Vec::new(),
16071 };
16072
16073 state.apply_tick_fields(&delta, 1);
16074
16075 assert_eq!(state.resource_nodes.len(), 1);
16076 assert_eq!(state.buildings.len(), 1);
16077 assert_eq!(state.doors.len(), 1);
16078 }
16079
16080 #[test]
16081 fn tick_updates_resource_nodes_when_server_sends_them() {
16082 let mut state = sample_state();
16083 let delta = TickDelta {
16084 tick: 1,
16085 entities: state.entities.clone(),
16086 resource_nodes: vec![ResourceNodeView {
16087 id: "oak-1".into(),
16088 label: "Oak".into(),
16089 x: 130.0,
16090 y: 128.0,
16091 z: 0.0,
16092 item_template: "oak_log".into(),
16093 state: ResourceNodeState::Cooldown,
16094 blocking: true,
16095 blocking_radius_m: 0.8,
16096 harvest_off: false,
16097 tile_id: None,
16098 yaw: 0.0,
16099 pitch: 0.0,
16100 roll: 0.0,
16101 draw_scale: 1.0,
16102 sprite_mode: None,
16103 growth_progress: None,
16104 presentation_state: None,
16105 channel_start_tick: None,
16106 channel_end_tick: None,
16107 harvest_drop_templates: vec![],
16108 }],
16109 buildings: vec![],
16110 doors: vec![],
16111 interior_map: None,
16112 npcs: vec![],
16113 inventory: vec![],
16114 blueprints: vec![],
16115 building_materials: vec![],
16116 world_clock: flatland_protocol::WorldClock::default(),
16117 ground_drops: vec![],
16118 placed_containers: vec![],
16119 combat: None,
16120 quest_log: vec![],
16121 hired_workers: Vec::new(),
16122 interactables: vec![],
16123 ledger: None,
16124 career: None,
16125 combat_fx: Vec::new(),
16126 ground_hazards: Vec::new(),
16127 property_plots: Vec::new(),
16128 terrain_overlays: Vec::new(),
16129 };
16130
16131 state.apply_tick_fields(&delta, 1);
16132
16133 assert!(matches!(
16134 state.resource_nodes[0].state,
16135 ResourceNodeState::Cooldown
16136 ));
16137 }
16138
16139 #[test]
16140 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
16141 let mut state = GameState {
16142 session_id: 1,
16143 entity_id: 1,
16144 character_id: None,
16145 tick: 0,
16146 chunk_rev: 0,
16147 content_rev: 0,
16148 publish_rev: 0,
16149 entities: vec![EntityState {
16150 id: 1,
16151 label: "You".into(),
16152 transform: Transform {
16153 position: WorldCoord::surface(4.5, 2.0),
16154 yaw: 0.0,
16155 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
16156 },
16157 vitals: None,
16158 attributes: None,
16159 skills: None,
16160 inside_building: Some("broker_hut".into()),
16161 tile_id: None,
16162 paperdoll_ref: None,
16163 draw_scale: 1.0,
16164 presentation_state: None,
16165 sprite_mode: None,
16166 progression_xp: None,
16167 combat_cues: vec![],
16168 statuses: vec![],
16169 }],
16170 player: None,
16171 resource_nodes: vec![],
16172 ground_drops: vec![],
16173 placed_containers: vec![],
16174 buildings: vec![BuildingView {
16175 id: "broker_hut".into(),
16176 label: "Broker".into(),
16177 x: 158.0,
16178 y: 124.0,
16179 width_m: 8.0,
16180 depth_m: 6.0,
16181 interior_blueprint: Some("broker_hut".into()),
16182 tags: vec![],
16183 market_boundary_zone_ids: vec![],
16184 market_max_volume: None,
16185 wall_set: None,
16186 roof_set: None,
16187 }],
16188 doors: vec![flatland_protocol::DoorView {
16189 id: "broker_hut_exit".into(),
16190 building_id: "broker_hut".into(),
16191 x: 4.3,
16192 y: 0.9,
16193 open: true,
16194 portal: Some("front".into()),
16195 locked: false,
16196 accessible: true,
16197 lock_id: None,
16198 }],
16199 interior_map: None,
16200 npcs: vec![flatland_protocol::NpcView {
16201 id: "ada_broker".into(),
16202 label: "Ada".into(),
16203 x: 4.5,
16204 y: 2.0,
16205 building_id: Some("broker_hut".into()),
16206 role: "broker".into(),
16207 entity_id: None,
16208 life_state: None,
16209 hp_pct: None,
16210 can_trade: true,
16211 buy_templates: vec!["lumber".into()],
16212 tile_id: None,
16213 behavior_state: None,
16214 presentation_state: None,
16215 sprite_mode: None,
16216 paperdoll_ref: None,
16217 draw_scale: 1.0,
16218 yaw: None,
16219 perception_fov_deg: None,
16220 perception_sight_m: None,
16221 perception_hear_m: None,
16222 }],
16223 blueprints: vec![],
16224 building_materials: vec![],
16225 world_x0: 0.0,
16226 world_y0: 0.0,
16227 world_width_m: 256.0,
16228 world_height_m: 256.0,
16229 terrain_zones: Vec::new(),
16230 z_platforms: Vec::new(),
16231 z_transitions: Vec::new(),
16232 z_bands_outdoor_backup: None,
16233 world_clock: flatland_protocol::WorldClock::default(),
16234 inventory: std::collections::HashMap::new(),
16235 inventory_hints: std::collections::HashMap::new(),
16236 logs: VecDeque::new(),
16237 intents_sent: 0,
16238 ticks_received: 0,
16239 connected: true,
16240 disconnect_reason: None,
16241 show_stats: false,
16242 hud_log_hidden: false,
16243 show_equip_menu: false,
16244 equip_menu_index: 0,
16245 show_craft_menu: false,
16246 show_plot_build_menu: false,
16247 plot_build_focus_wall: true,
16248 plot_build_wall_index: 0,
16249 plot_build_roof_index: 0,
16250 craft_menu_index: 0,
16251 craft_batch_quantity: 1,
16252 show_shop_menu: false,
16253 shop_catalog: None,
16254 bank_panel: None,
16255 bank_menu_index: 0,
16256 bank_ui_mode: BankUiMode::Menu,
16257 storage_panel: None,
16258 market_panel: None,
16259 market_menu_index: 0,
16260 market_filter: String::new(),
16261 market_filter_focused: false,
16262 market_category_filter: None,
16263 market_buy_confirm: None,
16264 market_ui_mode: MarketUiMode::Browse,
16265 storage_menu_index: 0,
16266 storage_ui_mode: StorageUiMode::Menu,
16267 shop_tab: ShopTab::default(),
16268 shop_menu_index: 0,
16269 shop_quantity: 1,
16270 shop_trade_log: VecDeque::new(),
16271 show_npc_verb_menu: false,
16272 npc_verb_target: None,
16273 npc_verb_index: 0,
16274 player_verbs: crate::social::PlayerVerbState::default(),
16275 social_chat: crate::social::SocialChatState::default(),
16276 trade_ui: crate::social::TradeUiState::default(),
16277 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
16278 show_npc_chat: false,
16279 npc_chat: None,
16280 show_inventory_menu: false,
16281 inventory_menu_index: 0,
16282 inventory_tab: InventoryTab::OnPerson,
16283 inventory_filter: String::new(),
16284 inventory_filter_focused: false,
16285 show_move_picker: false,
16286 show_rename_prompt: false,
16287 rename_plot_id: None,
16288 highlighted_plot_id: None,
16289 show_worker_rename: false,
16290 rename_buffer: String::new(),
16291 move_picker_index: 0,
16292 move_picker: None,
16293 show_grant_picker: false,
16294 grant_picker_index: 0,
16295 grant_picker: None,
16296 show_destroy_picker: false,
16297 destroy_confirm_pending: false,
16298 destroy_picker: None,
16299 combat_target: None,
16300 combat_target_label: None,
16301 ground_target: None,
16302 combat_fx: Vec::new(),
16303 ground_hazards: Vec::new(),
16304 property_zones: Vec::new(),
16305 tax_zones: Vec::new(),
16306 growth_zones: Vec::new(),
16307 biome_zones: Vec::new(),
16308 terrain_kind_nav: Vec::new(),
16309 property_plots: Vec::new(),
16310 property_plot_settings: None,
16311 claim_mode: None,
16312 relocate_mode: None,
16313 sell_plot_confirm: None,
16314 sell_plot_armed_at: None,
16315 show_plant_menu: false,
16316 plant_menu_index: 0,
16317 show_farm_access: false,
16318 farm_access_name_draft: String::new(),
16319 farm_access_discount_bps: 0,
16320 farm_access_index: 0,
16321 plant_quantity: 1,
16322 in_combat: false,
16323 auto_attack: true,
16324 combat_has_los: false,
16325 attack_cd_ticks: 0,
16326 gcd_ticks: 0,
16327 weapon_ability_id: "unarmed".into(),
16328 mainhand_template_id: None,
16329 mainhand_label: None,
16330 mainhand_instance_id: None,
16331 offhand_template_id: None,
16332 offhand_label: None,
16333 offhand_instance_id: None,
16334 mainhand_hand_slots: 1,
16335 defense: None,
16336 worn: BTreeMap::new(),
16337 carry_mass: 0.0,
16338 carry_mass_max: 0.0,
16339 encumbrance: flatland_protocol::EncumbranceState::Light,
16340 inventory_stacks: Vec::new(),
16341 keychain_stacks: Vec::new(),
16342 whisper_pouch_stacks: Vec::new(),
16343 combat_target_detail: None,
16344 statuses: Vec::new(),
16345 cast_progress: None,
16346 timed_channel: None,
16347 plot_build_offer: None,
16348 ability_cooldowns: Vec::new(),
16349 blocking_active: false,
16350 max_target_slots: 1,
16351 combat_slots: Vec::new(),
16352 rotation_presets: Vec::new(),
16353 known_abilities: Vec::new(),
16354 ability_meta: std::collections::HashMap::new(),
16355 ability_mastery: std::collections::HashMap::new(),
16356 hotbar: vec![None; 9],
16357 max_abilities_per_rotation: 0,
16358 show_loadout_menu: false,
16359 show_keychain_menu: false,
16360 keychain_menu_index: 0,
16361 show_rotation_editor: false,
16362 loadout_menu_index: 0,
16363 loadout_hotbar_slot: 1,
16364 loadout_ability_index: 0,
16365 loadout_focus_presets: false,
16366 rotation_editor: RotationEditorState::default(),
16367 harvest_in_progress: false,
16368 harvest_started_at: None,
16369 pending_craft_ack: None,
16370 pending_worker_job_ack: None,
16371 attending_worker_instance_id: None,
16372 quest_log: Vec::new(),
16373 interactables: Vec::new(),
16374 ledger: None,
16375 career: None,
16376 character_sheet_tab: CharacterSheetTab::Character,
16377 ledger_period: LedgerPeriod::Day,
16378 show_quest_offer: false,
16379 pending_quest_offer: None,
16380 show_quest_menu: false,
16381 quest_menu_index: 0,
16382 quest_withdraw_confirm: false,
16383 hired_workers: Vec::new(),
16384 show_workers_menu: false,
16385 workers_menu_index: 0,
16386 worker_dismiss_confirmation: None,
16387 workers_menu_compact: false,
16388 worker_step_display: BTreeMap::new(),
16389 worker_error_display: BTreeMap::new(),
16390 worker_health_ring_until: BTreeMap::new(),
16391 show_worker_give_picker: false,
16392 worker_give_picker_index: 0,
16393 worker_give_picker: None,
16394 show_worker_give_target_picker: false,
16395 worker_give_target_picker_index: 0,
16396 worker_give_target_picker: None,
16397 show_worker_take_picker: false,
16398 worker_take_picker_index: 0,
16399 worker_take_picker: None,
16400 show_worker_teach_picker: false,
16401 worker_teach_picker_index: 0,
16402 worker_teach_picker: None,
16403 worker_route_editor: None,
16404 progression_curve: None,
16405 };
16406 state.player = state.entities.first().cloned();
16407 assert_eq!(
16408 state.nearest_interact_target().as_deref(),
16409 Some("ada_broker")
16410 );
16411 }
16412
16413 #[test]
16414 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
16415 let mut state = sample_state();
16416 state.placed_containers = vec![
16419 flatland_protocol::PlacedContainerView {
16420 id: "near".into(),
16421 template_id: "wooden_chest_small".into(),
16422 display_name: "Wooden Chest".into(),
16423 x: 130.0,
16424 y: 128.0,
16425 z: 0.0,
16426 locked: true,
16427 accessible: true,
16428 owner_character_id: None,
16429 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
16430 lock_id: None,
16431 capacity_volume: None,
16432 item_instance_id: Some(uuid::Uuid::from_u128(1)),
16433 tile_id: None,
16434 worker_lodging_capacity: None,
16435 blocking: false,
16436 blocking_radius_m: 0.0,
16437 building_id: None,
16438 },
16439 flatland_protocol::PlacedContainerView {
16440 id: "far".into(),
16441 template_id: "wooden_chest_small".into(),
16442 display_name: "Distant Chest".into(),
16443 x: 128.0 + CONTAINER_RANGE_M + 5.0,
16444 y: 128.0,
16445 z: 0.0,
16446 locked: false,
16447 accessible: true,
16448 owner_character_id: None,
16449 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
16450 lock_id: None,
16451 capacity_volume: None,
16452 item_instance_id: Some(uuid::Uuid::from_u128(2)),
16453 tile_id: None,
16454 worker_lodging_capacity: None,
16455 blocking: false,
16456 blocking_radius_m: 0.0,
16457 building_id: None,
16458 },
16459 ];
16460
16461 let nearby = state.nearby_containers();
16462 assert_eq!(
16463 nearby.len(),
16464 1,
16465 "far chest must not appear once out of range"
16466 );
16467 assert_eq!(nearby[0].view.id, "near");
16468 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
16469 assert!(nearby[0].rows[0].is_chest_shell);
16470
16471 state.placed_containers[0].accessible = false;
16474 let nearby = state.nearby_containers();
16475 assert_eq!(nearby.len(), 1);
16476 assert_eq!(nearby[0].rows.len(), 1);
16477 assert!(nearby[0].rows[0].is_chest_shell);
16478 }
16479
16480 #[test]
16481 fn chest_pickup_destinations_offer_person_and_worn_bag() {
16482 let mut state = sample_state();
16483 let back_id = uuid::Uuid::from_u128(42);
16484 state.worn.insert(
16485 BodySlot::Back,
16486 flatland_protocol::ItemStack {
16487 template_id: "travel_backpack".into(),
16488 quantity: 1,
16489 item_instance_id: Some(back_id),
16490 props: Default::default(),
16491 status_bindings: Vec::new(),
16492 contents: Vec::new(),
16493 display_name: Some("Travel Backpack".into()),
16494 category: Some("container".into()),
16495 base_mass: Some(2.5),
16496 base_volume: Some(12.0),
16497 capacity_volume: Some(80.0),
16498 stackable: Some(false),
16499 world_placeable: Some(false),
16500 worker_lodging_capacity: None,
16501 equip_slot: None,
16502 armor_physical: None,
16503 resists: vec![],
16504 hand_slots: None,
16505 listable: None,
16506 ..Default::default()
16507 },
16508 );
16509 let opts = state.chest_pickup_destinations("chest-1");
16510 assert!(matches!(
16511 opts.first().map(|o| &o.kind),
16512 Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
16513 ));
16514 assert!(opts.iter().any(|o| matches!(
16515 &o.kind,
16516 MoveOptionKind::PickupPlaced {
16517 nest_parent_instance_id: None,
16518 ..
16519 }
16520 )));
16521 assert!(opts.iter().any(|o| matches!(
16522 &o.kind,
16523 MoveOptionKind::PickupPlaced {
16524 nest_parent_instance_id: Some(id),
16525 ..
16526 } if *id == back_id
16527 )));
16528 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
16529 }
16530
16531 #[test]
16532 fn placed_container_public_label_hides_owner_custom_name() {
16533 let owner = uuid::Uuid::from_u128(99);
16534 let mut state = sample_state();
16535 state.character_id = Some(uuid::Uuid::from_u128(1));
16536 state.inventory_hints.insert(
16537 "wooden_chest_medium".into(),
16538 InventoryHint {
16539 display_name: "Medium Wooden Chest".into(),
16540 category: "container".into(),
16541 base_mass: None,
16542 base_volume: None,
16543 capacity_volume: None,
16544 stackable: false,
16545 listable: true,
16546 base_value_copper: None,
16547 },
16548 );
16549 let chest = flatland_protocol::PlacedContainerView {
16550 id: "c1".into(),
16551 template_id: "wooden_chest_medium".into(),
16552 display_name: "Barry's Loot #a3f2".into(),
16553 x: 128.0,
16554 y: 128.0,
16555 z: 0.0,
16556 locked: false,
16557 accessible: true,
16558 owner_character_id: Some(owner),
16559 contents: vec![],
16560 lock_id: None,
16561 capacity_volume: None,
16562 item_instance_id: None,
16563 tile_id: None,
16564 worker_lodging_capacity: None,
16565 blocking: false,
16566 blocking_radius_m: 0.0,
16567 building_id: None,
16568 };
16569 assert_eq!(
16570 state.placed_container_public_label(&chest),
16571 "Medium Wooden Chest"
16572 );
16573 state.character_id = Some(owner);
16574 assert_eq!(
16575 state.placed_container_public_label(&chest),
16576 "Barry's Loot #a3f2"
16577 );
16578 }
16579
16580 #[test]
16581 fn location_context_shows_crop_growth_percent_not_depleted() {
16582 let mut state = sample_state();
16583 state.player = state.entities.first().cloned();
16584 state.resource_nodes[0].label = "Carrot (growing)".into();
16585 state.resource_nodes[0].x = 128.2;
16586 state.resource_nodes[0].y = 128.0;
16587 state.resource_nodes[0].state = ResourceNodeState::Cooldown;
16588 state.resource_nodes[0].growth_progress = Some(0.47);
16589 let lines = state.location_context_lines();
16590 let line = lines
16591 .iter()
16592 .find(|l| l.text.contains("Carrot"))
16593 .map(|l| l.text.as_str())
16594 .unwrap_or("");
16595 assert!(
16596 line.contains("(growing, 47%)"),
16597 "expected growth percent, got: {line}"
16598 );
16599 assert!(
16600 !line.contains("depleted"),
16601 "growing crop should not show depleted: {line}"
16602 );
16603 }
16604
16605 #[test]
16606 fn resource_node_near_action_suffix_prefers_growth() {
16607 let node = ResourceNodeView {
16608 id: "crop".into(),
16609 label: "Wheat".into(),
16610 x: 0.0,
16611 y: 0.0,
16612 z: 0.0,
16613 item_template: "wheat".into(),
16614 state: ResourceNodeState::Cooldown,
16615 blocking: false,
16616 blocking_radius_m: 0.0,
16617 harvest_off: false,
16618 tile_id: None,
16619 yaw: 0.0,
16620 pitch: 0.0,
16621 roll: 0.0,
16622 draw_scale: 1.0,
16623 sprite_mode: None,
16624 growth_progress: Some(0.12),
16625 presentation_state: None,
16626 channel_start_tick: None,
16627 channel_end_tick: None,
16628 harvest_drop_templates: vec![],
16629 };
16630 assert_eq!(resource_node_near_action_suffix(&node), " (growing, 12%)");
16631 }
16632
16633 #[test]
16634 fn location_context_lists_nearby_resource_node() {
16635 let mut state = sample_state();
16636 state.player = state.entities.first().cloned();
16637 state.resource_nodes[0].x = 128.2;
16638 state.resource_nodes[0].y = 128.0;
16639 let lines = state.location_context_lines();
16640 assert!(
16641 lines
16642 .iter()
16643 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
16644 "expected resource node in context: {:?}",
16645 lines
16646 );
16647 }
16648
16649 #[test]
16650 fn quest_board_usable_within_board_radius() {
16651 let mut state = sample_state();
16652 state.player = state.entities.first().cloned();
16653 state.interactables = vec![flatland_protocol::InteractableView {
16654 id: "board-1".into(),
16655 kind: "quest_board".into(),
16656 label: "Town Quest Board".into(),
16657 x: 130.5,
16658 y: 128.0,
16659 z: 0.0,
16660 board_id: Some("starter_town_board".into()),
16661 }];
16662 assert_eq!(
16664 state.nearest_interact_target().as_deref(),
16665 Some("board-1"),
16666 "quest board should be selectable at ~2.5m"
16667 );
16668 let lines = state.location_context_lines();
16669 assert!(
16670 lines
16671 .iter()
16672 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
16673 "HUD should advertise f when board is in range: {:?}",
16674 lines
16675 );
16676 }
16677
16678 #[test]
16679 fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
16680 let mut state = sample_state();
16681 state.worn.insert(
16682 BodySlot::Back,
16683 flatland_protocol::ItemStack {
16684 template_id: "travel_backpack".into(),
16685 quantity: 1,
16686 item_instance_id: Some(uuid::Uuid::from_u128(3)),
16687 props: Default::default(),
16688 status_bindings: Vec::new(),
16689 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
16690 display_name: None,
16691 category: None,
16692 base_mass: None,
16693 base_volume: None,
16694 capacity_volume: None,
16695 stackable: None,
16696 world_placeable: None,
16697 worker_lodging_capacity: None,
16698 equip_slot: None,
16699 armor_physical: None,
16700 resists: vec![],
16701 hand_slots: None,
16702 listable: None,
16703 ..Default::default()
16704 },
16705 );
16706 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
16707 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16708 id: "chest-1".into(),
16709 template_id: "wooden_chest_small".into(),
16710 display_name: "Wooden Chest".into(),
16711 x: 129.0,
16712 y: 128.0,
16713 z: 0.0,
16714 locked: false,
16715 accessible: true,
16716 owner_character_id: None,
16717 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
16718 lock_id: None,
16719 capacity_volume: None,
16720 item_instance_id: Some(uuid::Uuid::from_u128(4)),
16721 tile_id: None,
16722 worker_lodging_capacity: None,
16723 blocking: false,
16724 blocking_radius_m: 0.0,
16725 building_id: None,
16726 }];
16727
16728 state.inventory_tab = InventoryTab::OnPerson;
16729 let rows = state.inventory_selectable_rows();
16730 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
16731 assert_eq!(
16732 sections,
16733 vec![
16734 InventorySection::Worn, InventorySection::Worn, InventorySection::Person, ]
16738 );
16739 assert_eq!(rows[0].stack.template_id, "travel_backpack");
16740 assert!(rows[0].is_equip_shell);
16741 assert_eq!(rows[1].stack.template_id, "iron_ore");
16742 assert_eq!(rows[1].depth, 1);
16743 assert_eq!(rows[2].stack.template_id, "lumber");
16744
16745 let lines = state.inventory_browser_lines();
16746 assert!(lines.iter().any(|l| matches!(
16747 l,
16748 InventoryBrowserLine::Section(s) if s.contains("Worn")
16749 )));
16750 assert!(lines.iter().any(|l| matches!(
16751 l,
16752 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
16753 || text.contains("backpack")
16754 )));
16755 assert!(!lines.iter().any(|l| matches!(
16756 l,
16757 InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
16758 )));
16759
16760 state.inventory_tab = InventoryTab::Nearby;
16761 let nearby_rows = state.inventory_selectable_rows();
16762 assert_eq!(nearby_rows.len(), 2);
16763 assert!(nearby_rows[0].is_chest_shell);
16764 assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
16765 let nearby_lines = state.inventory_browser_lines();
16766 assert!(nearby_lines.iter().any(|l| matches!(
16767 l,
16768 InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
16769 )));
16770 }
16771
16772 #[test]
16773 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
16774 let mut state = sample_state();
16775 let back_id = uuid::Uuid::from_u128(5);
16776 state.worn.insert(
16777 BodySlot::Back,
16778 flatland_protocol::ItemStack {
16779 template_id: "travel_backpack".into(),
16780 quantity: 1,
16781 item_instance_id: Some(back_id),
16782 props: Default::default(),
16783 status_bindings: Vec::new(),
16784 contents: Vec::new(),
16785 display_name: None,
16786 category: Some("container".into()),
16787 base_mass: None,
16788 base_volume: None,
16789 capacity_volume: Some(80.0),
16790 stackable: None,
16791 world_placeable: None,
16792 worker_lodging_capacity: None,
16793 equip_slot: None,
16794 armor_physical: None,
16795 resists: vec![],
16796 hand_slots: None,
16797 listable: None,
16798 ..Default::default()
16799 },
16800 );
16801 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16802 id: "chest-1".into(),
16803 template_id: "wooden_chest_small".into(),
16804 display_name: "Wooden Chest".into(),
16805 x: 129.0,
16806 y: 128.0,
16807 z: 0.0,
16808 locked: false,
16809 accessible: true,
16810 owner_character_id: None,
16811 contents: Vec::new(),
16812 lock_id: None,
16813 capacity_volume: None,
16814 item_instance_id: Some(uuid::Uuid::from_u128(6)),
16815 tile_id: None,
16816 worker_lodging_capacity: None,
16817 blocking: false,
16818 blocking_radius_m: 0.0,
16819 building_id: None,
16820 }];
16821
16822 let opts = state.move_destinations_for(
16825 &flatland_protocol::InventoryLocation::Root,
16826 None,
16827 None,
16828 "lumber",
16829 );
16830 assert!(!opts.iter().any(|o| matches!(
16831 &o.kind,
16832 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
16833 )));
16834 assert!(opts.iter().any(|o| matches!(
16835 &o.kind,
16836 MoveOptionKind::Move { location, parent_instance_id, .. }
16837 if *location == flatland_protocol::InventoryLocation::Worn {
16838 slot: BodySlot::Back,
16839 } && *parent_instance_id == Some(back_id)
16840 )));
16841 assert!(opts.iter().any(|o| matches!(
16842 &o.kind,
16843 MoveOptionKind::Move { location, .. }
16844 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
16845 )));
16846 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
16847 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
16848
16849 let from_backpack = flatland_protocol::InventoryLocation::Worn {
16853 slot: BodySlot::Back,
16854 };
16855 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
16856 assert!(!opts.iter().any(|o| matches!(
16857 &o.kind,
16858 MoveOptionKind::Move { location, parent_instance_id, .. }
16859 if *location == from_backpack && *parent_instance_id == Some(back_id)
16860 )));
16861 assert!(opts.iter().any(|o| matches!(
16862 &o.kind,
16863 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
16864 )));
16865 }
16866
16867 #[test]
16868 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
16869 let mut state = sample_state();
16870 state.worn.insert(
16873 BodySlot::Waist,
16874 flatland_protocol::ItemStack {
16875 template_id: "simple_belt".into(),
16876 quantity: 1,
16877 item_instance_id: Some(uuid::Uuid::from_u128(10)),
16878 props: Default::default(),
16879 status_bindings: Vec::new(),
16880 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
16881 display_name: None,
16882 category: Some("container".into()),
16883 base_mass: None,
16884 base_volume: None,
16885 capacity_volume: None,
16886 stackable: None,
16887 world_placeable: None,
16888 worker_lodging_capacity: None,
16889 equip_slot: None,
16890 armor_physical: None,
16891 resists: vec![],
16892 hand_slots: None,
16893 listable: None,
16894 ..Default::default()
16895 },
16896 );
16897 state.worn.insert(
16898 BodySlot::Head,
16899 flatland_protocol::ItemStack {
16900 template_id: "cloth_cap".into(),
16901 quantity: 1,
16902 item_instance_id: Some(uuid::Uuid::from_u128(11)),
16903 props: Default::default(),
16904 status_bindings: Vec::new(),
16905 contents: Vec::new(),
16906 display_name: None,
16907 category: Some("armor".into()),
16908 base_mass: None,
16909 base_volume: None,
16910 capacity_volume: None,
16911 stackable: None,
16912 world_placeable: None,
16913 worker_lodging_capacity: None,
16914 equip_slot: None,
16915 armor_physical: None,
16916 resists: vec![],
16917 hand_slots: None,
16918 listable: None,
16919 ..Default::default()
16920 },
16921 );
16922 state.worn.insert(
16923 BodySlot::Back,
16924 flatland_protocol::ItemStack {
16925 template_id: "travel_backpack".into(),
16926 quantity: 1,
16927 item_instance_id: Some(uuid::Uuid::from_u128(12)),
16928 props: Default::default(),
16929 status_bindings: Vec::new(),
16930 contents: Vec::new(),
16931 display_name: None,
16932 category: Some("container".into()),
16933 base_mass: None,
16934 base_volume: None,
16935 capacity_volume: None,
16936 stackable: None,
16937 world_placeable: None,
16938 worker_lodging_capacity: None,
16939 equip_slot: None,
16940 armor_physical: None,
16941 resists: vec![],
16942 hand_slots: None,
16943 listable: None,
16944 ..Default::default()
16945 },
16946 );
16947
16948 let rows = state.worn_rows();
16949 assert_eq!(rows.len(), 4);
16951 assert_eq!(rows[0].stack.template_id, "cloth_cap");
16952 assert!(rows[0].is_equip_shell);
16953 assert_eq!(rows[1].stack.template_id, "travel_backpack");
16954 assert!(rows[1].is_equip_shell);
16955 assert_eq!(rows[2].stack.template_id, "simple_belt");
16956 assert!(rows[2].is_equip_shell);
16957 assert_eq!(rows[3].stack.template_id, "leather_pouch");
16958 assert_eq!(rows[3].depth, 1);
16959 assert!(!rows[3].is_equip_shell);
16960 }
16961
16962 #[test]
16963 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
16964 let mut state = sample_state();
16965 state.worn.insert(
16966 BodySlot::Waist,
16967 flatland_protocol::ItemStack {
16968 template_id: "simple_belt".into(),
16969 quantity: 1,
16970 item_instance_id: Some(uuid::Uuid::from_u128(20)),
16971 props: Default::default(),
16972 status_bindings: Vec::new(),
16973 contents: Vec::new(),
16974 display_name: Some("Simple Belt".into()),
16975 category: Some("container".into()),
16976 base_mass: None,
16977 base_volume: None,
16978 capacity_volume: None,
16979 stackable: None,
16980 world_placeable: None,
16981 worker_lodging_capacity: None,
16982 equip_slot: None,
16983 armor_physical: None,
16984 resists: vec![],
16985 hand_slots: None,
16986 listable: None,
16987 ..Default::default()
16988 },
16989 );
16990 state.worn.insert(
16991 BodySlot::Head,
16992 flatland_protocol::ItemStack {
16993 template_id: "cloth_cap".into(),
16994 quantity: 1,
16995 item_instance_id: Some(uuid::Uuid::from_u128(21)),
16996 props: Default::default(),
16997 status_bindings: Vec::new(),
16998 contents: Vec::new(),
16999 display_name: Some("Cloth Cap".into()),
17000 category: Some("armor".into()),
17001 base_mass: None,
17002 base_volume: None,
17003 capacity_volume: None,
17004 stackable: None,
17005 world_placeable: None,
17006 worker_lodging_capacity: None,
17007 equip_slot: None,
17008 armor_physical: None,
17009 resists: vec![],
17010 hand_slots: None,
17011 listable: None,
17012 ..Default::default()
17013 },
17014 );
17015
17016 let opts = state.move_destinations_for(
17017 &flatland_protocol::InventoryLocation::Root,
17018 None,
17019 None,
17020 "leather_pouch",
17021 );
17022 assert!(
17023 opts.iter().any(|o| matches!(
17024 &o.kind,
17025 MoveOptionKind::Move { location, .. }
17026 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
17027 )),
17028 "belt loop must be offered when moving a pouch"
17029 );
17030 assert!(
17031 !opts.iter().any(|o| matches!(
17032 &o.kind,
17033 MoveOptionKind::Move { location, .. }
17034 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
17035 )),
17036 "armor slots can't hold other items and must not appear as move destinations"
17037 );
17038 let belt_opt = opts
17039 .iter()
17040 .find(|o| matches!(
17041 &o.kind,
17042 MoveOptionKind::Move { location, .. }
17043 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
17044 ))
17045 .unwrap();
17046 assert!(belt_opt.label.contains("belt loop"));
17047
17048 let opts = state.move_destinations_for(
17049 &flatland_protocol::InventoryLocation::Root,
17050 None,
17051 None,
17052 "lumber",
17053 );
17054 assert!(
17055 !opts.iter().any(|o| o.label.contains("belt loop")),
17056 "loose materials must not target the belt shell — only nested pouches"
17057 );
17058 }
17059
17060 #[test]
17061 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
17062 let mut state = sample_state();
17063 let belt_id = uuid::Uuid::from_u128(30);
17064 let pouch_id = uuid::Uuid::from_u128(31);
17065 state.worn.insert(
17066 BodySlot::Waist,
17067 flatland_protocol::ItemStack {
17068 template_id: "simple_belt".into(),
17069 quantity: 1,
17070 item_instance_id: Some(belt_id),
17071 props: Default::default(),
17072 status_bindings: Vec::new(),
17073 world_placeable: None,
17074 worker_lodging_capacity: None,
17075 equip_slot: None,
17076 armor_physical: None,
17077 resists: vec![],
17078 hand_slots: None,
17079 contents: vec![flatland_protocol::ItemStack {
17080 template_id: "dimensional_pouch".into(),
17081 quantity: 1,
17082 item_instance_id: Some(pouch_id),
17083 props: Default::default(),
17084 status_bindings: Vec::new(),
17085 contents: Vec::new(),
17086 display_name: Some("Dimensional Pouch".into()),
17087 category: Some("container".into()),
17088 base_mass: None,
17089 base_volume: None,
17090 capacity_volume: Some(200.0),
17091 stackable: None,
17092 world_placeable: None,
17093 worker_lodging_capacity: None,
17094 equip_slot: None,
17095 armor_physical: None,
17096 resists: vec![],
17097 hand_slots: None,
17098 listable: None,
17099 ..Default::default()
17100 }],
17101 display_name: Some("Simple Belt".into()),
17102 category: Some("container".into()),
17103 base_mass: None,
17104 base_volume: None,
17105 capacity_volume: None,
17106 stackable: None,
17107 listable: None,
17108 ..Default::default()
17109 },
17110 );
17111
17112 let opts = state.move_destinations_for(
17113 &flatland_protocol::InventoryLocation::Root,
17114 None,
17115 None,
17116 "iron_ore",
17117 );
17118 assert!(
17119 opts.iter().any(|o| matches!(
17120 &o.kind,
17121 MoveOptionKind::Move {
17122 location,
17123 parent_instance_id,
17124 ..
17125 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
17126 && *parent_instance_id == Some(pouch_id)
17127 )),
17128 "dimensional pouch clipped on belt must accept loose items"
17129 );
17130 assert!(
17131 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
17132 "destination label should name the pouch"
17133 );
17134 }
17135
17136 #[test]
17137 fn container_volume_label_on_placed_chest_shell() {
17138 let mut state = sample_state();
17139 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17140 id: "chest-1".into(),
17141 template_id: "wooden_chest_small".into(),
17142 display_name: "Camp Chest".into(),
17143 x: 129.0,
17144 y: 128.0,
17145 z: 0.0,
17146 locked: false,
17147 accessible: true,
17148 owner_character_id: None,
17149 contents: vec![flatland_protocol::ItemStack {
17150 template_id: "iron_ore".into(),
17151 quantity: 2,
17152 item_instance_id: None,
17153 props: Default::default(),
17154 status_bindings: Vec::new(),
17155 contents: Vec::new(),
17156 display_name: None,
17157 category: None,
17158 base_mass: None,
17159 base_volume: Some(2.0),
17160 capacity_volume: None,
17161 stackable: None,
17162 world_placeable: None,
17163 worker_lodging_capacity: None,
17164 equip_slot: None,
17165 armor_physical: None,
17166 resists: vec![],
17167 hand_slots: None,
17168 listable: None,
17169 ..Default::default()
17170 }],
17171 lock_id: None,
17172 capacity_volume: Some(60.0),
17173 item_instance_id: Some(uuid::Uuid::from_u128(4)),
17174 tile_id: None,
17175 worker_lodging_capacity: None,
17176 blocking: false,
17177 blocking_radius_m: 0.0,
17178 building_id: None,
17179 }];
17180 let nearby = state.nearby_containers();
17181 let label = state.container_volume_label(&nearby[0].rows[0]);
17182 assert!(
17183 label.contains("vol 4/60"),
17184 "expected used/cap in label, got {label}"
17185 );
17186 assert!(
17187 label.contains("56 free"),
17188 "expected free space, got {label}"
17189 );
17190 }
17191
17192 #[test]
17193 fn key_pair_chest_label_from_placed_lock_id() {
17194 let mut state = sample_state();
17195 let owner = uuid::Uuid::from_u128(77);
17196 state.character_id = Some(owner);
17197 let lock = uuid::Uuid::from_u128(99).to_string();
17198 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17199 id: "chest-1".into(),
17200 template_id: "wooden_chest_small".into(),
17201 display_name: "Barry's Loot #a3f2".into(),
17202 x: 129.0,
17203 y: 128.0,
17204 z: 0.0,
17205 locked: true,
17206 accessible: true,
17207 owner_character_id: Some(owner),
17208 contents: Vec::new(),
17209 lock_id: Some(lock.clone()),
17210 capacity_volume: None,
17211 item_instance_id: Some(uuid::Uuid::from_u128(4)),
17212 tile_id: None,
17213 worker_lodging_capacity: None,
17214 blocking: false,
17215 blocking_radius_m: 0.0,
17216 building_id: None,
17217 }];
17218 let key_id = uuid::Uuid::from_u128(5);
17219 let key = flatland_protocol::ItemStack {
17220 template_id: KEY_TEMPLATE.into(),
17221 quantity: 1,
17222 item_instance_id: Some(key_id),
17223 props: BTreeMap::from([
17224 (PROP_OPENS_LOCK_ID.into(), lock),
17225 (
17226 PROP_OPENS_CONTAINER_NAME.into(),
17227 "Barry's Loot #a3f2".into(),
17228 ),
17229 ]),
17230 status_bindings: Vec::new(),
17231 contents: Vec::new(),
17232 display_name: Some("Container Key".into()),
17233 category: Some("key".into()),
17234 base_mass: None,
17235 base_volume: None,
17236 capacity_volume: None,
17237 stackable: None,
17238 world_placeable: None,
17239 worker_lodging_capacity: None,
17240 equip_slot: None,
17241 armor_physical: None,
17242 resists: vec![],
17243 hand_slots: None,
17244 listable: None,
17245 ..Default::default()
17246 };
17247 state.inventory_stacks = vec![key.clone()];
17248 assert_eq!(
17249 state.key_pair_chest_label(&key).as_deref(),
17250 Some("Barry's Loot #a3f2")
17251 );
17252 assert!(state.key_drop_blocked(&key));
17253 }
17254
17255 #[test]
17256 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
17257 let mut state = sample_state();
17258 let lock = uuid::Uuid::from_u128(101).to_string();
17259 let key = flatland_protocol::ItemStack {
17260 template_id: KEY_TEMPLATE.into(),
17261 quantity: 1,
17262 item_instance_id: Some(uuid::Uuid::from_u128(7)),
17263 props: BTreeMap::from([
17264 (PROP_OPENS_LOCK_ID.into(), lock),
17265 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
17266 ]),
17267 status_bindings: Vec::new(),
17268 contents: Vec::new(),
17269 display_name: None,
17270 category: Some("key".into()),
17271 base_mass: None,
17272 base_volume: None,
17273 capacity_volume: None,
17274 stackable: None,
17275 world_placeable: None,
17276 worker_lodging_capacity: None,
17277 equip_slot: None,
17278 armor_physical: None,
17279 resists: vec![],
17280 hand_slots: None,
17281 listable: None,
17282 ..Default::default()
17283 };
17284 state.placed_containers.clear();
17285 assert_eq!(
17286 state.key_pair_chest_label(&key).as_deref(),
17287 Some("Camp Stash")
17288 );
17289 }
17290
17291 #[test]
17292 fn key_drop_allowed_when_paired_chest_unlocked() {
17293 let mut state = sample_state();
17294 let lock = uuid::Uuid::from_u128(100).to_string();
17295 let key_id = uuid::Uuid::from_u128(6);
17296 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17297 id: "chest-1".into(),
17298 template_id: "wooden_chest_small".into(),
17299 display_name: "Camp Chest".into(),
17300 x: 129.0,
17301 y: 128.0,
17302 z: 0.0,
17303 locked: false,
17304 accessible: true,
17305 owner_character_id: None,
17306 contents: Vec::new(),
17307 lock_id: Some(lock.clone()),
17308 capacity_volume: None,
17309 item_instance_id: None,
17310 tile_id: None,
17311 worker_lodging_capacity: None,
17312 blocking: false,
17313 blocking_radius_m: 0.0,
17314 building_id: None,
17315 }];
17316 let key = flatland_protocol::ItemStack {
17317 template_id: KEY_TEMPLATE.into(),
17318 quantity: 1,
17319 item_instance_id: Some(key_id),
17320 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
17321 status_bindings: Vec::new(),
17322 contents: Vec::new(),
17323 display_name: None,
17324 category: Some("key".into()),
17325 base_mass: None,
17326 base_volume: None,
17327 capacity_volume: None,
17328 stackable: None,
17329 world_placeable: None,
17330 worker_lodging_capacity: None,
17331 equip_slot: None,
17332 armor_physical: None,
17333 resists: vec![],
17334 hand_slots: None,
17335 listable: None,
17336 ..Default::default()
17337 };
17338 state.inventory_stacks = vec![key.clone()];
17339 assert!(!state.key_drop_blocked(&key));
17340 let opts = state.move_destinations_for(
17341 &flatland_protocol::InventoryLocation::Root,
17342 None,
17343 Some(key_id),
17344 KEY_TEMPLATE,
17345 );
17346 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
17347 }
17348
17349 #[test]
17350 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
17351 use flatland_protocol::{CombatHud, ProgressionCurve, ProgressionXp};
17352
17353 let mut state = sample_state();
17354 let curve = ProgressionCurve::default();
17355 let bootstrap =
17356 ProgressionXp::bootstrap_new(curve.baseline_display, curve.xp_base, curve.xp_growth);
17357 let mut fresh = bootstrap.clone();
17358 fresh.strength += 0.08;
17359 if let Some(player) = state.player.as_mut() {
17360 player.progression_xp = Some(bootstrap);
17361 }
17362
17363 let combat = CombatHud {
17364 progression_xp: Some(fresh.clone()),
17365 progression_baseline: curve.baseline_display,
17366 progression_xp_base: curve.xp_base,
17367 progression_xp_growth: curve.xp_growth,
17368 attributes: state.player.as_ref().and_then(|p| p.attributes),
17369 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
17370 ..CombatHud::default()
17371 };
17372 state.apply_combat_hud(&combat);
17373
17374 let xp = state
17375 .player
17376 .as_ref()
17377 .and_then(|p| p.progression_xp.as_ref())
17378 .expect("xp");
17379 assert!((xp.strength - fresh.strength).abs() < 0.001);
17380 assert!(state.progression_curve.is_some());
17381 }
17382
17383 #[test]
17384 fn combat_hud_syncs_known_abilities_and_hotbar() {
17385 use flatland_protocol::CombatHud;
17386
17387 let mut state = sample_state();
17388 let combat = CombatHud {
17389 known_abilities: vec!["unarmed".into(), "fireball".into()],
17390 hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
17391 max_abilities_per_rotation: 4,
17392 ability_id: "short_sword_slash".into(),
17393 ..CombatHud::default()
17394 };
17395 state.apply_combat_hud(&combat);
17396
17397 assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
17398 assert_eq!(state.hotbar_ability(1), Some("fireball"));
17399 assert_eq!(state.hotbar_ability(2), None);
17400 assert_eq!(state.hotbar_ability(3), Some("unarmed"));
17401 assert_eq!(state.max_abilities_per_rotation, 4);
17402 let choices = state.loadout_ability_choices();
17403 assert!(choices.iter().any(|a| a == "short_sword_slash"));
17404 assert!(choices.iter().any(|a| a == "fireball"));
17405 }
17406
17407 #[test]
17408 fn loadout_hotbar_choices_include_inventory_consumables() {
17409 let mut state = sample_state();
17410 state.known_abilities = vec!["unarmed".into()];
17411 state.weapon_ability_id = "unarmed".into();
17412 state.inventory_stacks = vec![flatland_protocol::ItemStack {
17413 template_id: "bottle_of_water".into(),
17414 quantity: 3,
17415 item_instance_id: Some(uuid::Uuid::from_u128(9)),
17416 display_name: Some("Bottle of Water".into()),
17417 category: Some("consumable".into()),
17418 ..Default::default()
17419 }];
17420 state.inventory.insert("bottle_of_water".into(), 3);
17421 state.inventory_hints.insert(
17422 "bottle_of_water".into(),
17423 InventoryHint {
17424 display_name: "Bottle of Water".into(),
17425 category: "consumable".into(),
17426 ..Default::default()
17427 },
17428 );
17429
17430 let choices = state.loadout_hotbar_choices();
17431 assert!(choices.iter().any(|c| c.binding == "unarmed"));
17432 let water = choices
17433 .iter()
17434 .find(|c| c.binding == "item:bottle_of_water")
17435 .expect("water binding");
17436 assert_eq!(water.meta.as_deref(), Some("use"));
17437 assert!(water.label.contains("Water"));
17438 assert_eq!(state.hotbar_slot_label(1), None, "unbound until set");
17439 state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
17440 assert_eq!(
17441 state.hotbar_slot_label(5).as_deref(),
17442 Some("Bottle of Water×3")
17443 );
17444 }
17445
17446 #[test]
17447 fn storage_store_options_excludes_hand_equipped() {
17448 let mut state = sample_state();
17449 let sword_id = uuid::Uuid::from_u128(11);
17450 let ore_id = uuid::Uuid::from_u128(22);
17451 state.inventory_stacks = vec![
17452 flatland_protocol::ItemStack {
17453 template_id: "short_sword".into(),
17454 quantity: 1,
17455 item_instance_id: Some(sword_id),
17456 display_name: Some("Short Sword".into()),
17457 category: Some("weapon".into()),
17458 ..Default::default()
17459 },
17460 flatland_protocol::ItemStack {
17461 template_id: "iron_ore".into(),
17462 quantity: 5,
17463 item_instance_id: Some(ore_id),
17464 display_name: Some("Iron Ore".into()),
17465 category: Some("resource".into()),
17466 ..Default::default()
17467 },
17468 ];
17469 state.mainhand_template_id = Some("short_sword".into());
17470 state.mainhand_instance_id = Some(sword_id);
17471
17472 let opts = state.storage_store_options();
17473 assert_eq!(opts.len(), 1);
17474 assert_eq!(opts[0].item_instance_id, ore_id);
17475 assert!(state.hand_equipped_instance_ids().contains(&sword_id));
17476 }
17477
17478 #[test]
17479 fn loose_consumable_move_picker_offers_use_and_storage() {
17480 let mut state = sample_state();
17481 let inst = uuid::Uuid::from_u128(77);
17482 state.inventory_stacks = vec![flatland_protocol::ItemStack {
17483 template_id: "carrot".into(),
17484 quantity: 2,
17485 item_instance_id: Some(inst),
17486 props: Default::default(),
17487 status_bindings: Vec::new(),
17488 contents: Vec::new(),
17489 display_name: Some("Wild Carrot".into()),
17490 category: Some("consumable".into()),
17491 base_mass: None,
17492 base_volume: None,
17493 capacity_volume: None,
17494 stackable: Some(true),
17495 world_placeable: None,
17496 worker_lodging_capacity: None,
17497 equip_slot: None,
17498 armor_physical: None,
17499 resists: vec![],
17500 hand_slots: None,
17501 listable: None,
17502 ..Default::default()
17503 }];
17504 state.inventory_hints.insert(
17505 "carrot".into(),
17506 InventoryHint {
17507 display_name: "Wild Carrot".into(),
17508 category: "consumable".into(),
17509 base_mass: Some(0.15),
17510 base_volume: Some(0.3),
17511 capacity_volume: None,
17512 stackable: true,
17513 listable: true,
17514 base_value_copper: None,
17515 },
17516 );
17517 state.show_inventory_menu = true;
17518 state.inventory_menu_index = 0;
17519
17520 let row = state.inventory_selected_row().expect("carrot row");
17521 let mut options = state.move_destinations_for(
17522 &row.from,
17523 row.from_parent_instance_id,
17524 row.stack.item_instance_id,
17525 &row.stack.template_id,
17526 );
17527 if row.from == flatland_protocol::InventoryLocation::Root
17528 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
17529 {
17530 options.insert(
17531 0,
17532 MoveOption {
17533 label: "Use (eat / drink)".into(),
17534 kind: MoveOptionKind::Use,
17535 },
17536 );
17537 }
17538
17539 assert_eq!(
17540 options.first().map(|o| &o.label),
17541 Some(&"Use (eat / drink)".into())
17542 );
17543 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
17544 assert!(options
17545 .iter()
17546 .any(|o| matches!(o.kind, MoveOptionKind::Drop)));
17547 }
17548
17549 #[test]
17550 fn inventory_category_group_order_is_stable() {
17551 assert_eq!(inventory_category_group("weapon").0, "Weapons");
17552 assert_eq!(inventory_category_group("armor").0, "Armor");
17553 assert_eq!(inventory_category_group("consumable").0, "Consumables");
17554 assert_eq!(inventory_category_group("resource").0, "Resources");
17555 assert_eq!(inventory_category_group("container").0, "Containers");
17556 assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
17557 assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
17558 }
17559
17560 #[test]
17561 fn page_list_index_clamps_without_wrap() {
17562 assert_eq!(page_list_index(0, -1, 25), 0);
17563 assert_eq!(page_list_index(0, 1, 25), 10);
17564 assert_eq!(page_list_index(12, 1, 25), 22);
17565 assert_eq!(page_list_index(22, 1, 25), 24);
17566 assert_eq!(page_list_index(5, 1, 0), 0);
17567 assert_eq!(page_list_index(3, -1, 8), 0);
17568 }
17569
17570 #[test]
17571 fn inventory_filter_hides_non_matching_person_items() {
17572 let mut state = sample_state();
17573 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
17574 sword.display_name = Some("Iron Sword".into());
17575 sword.category = Some("weapon".into());
17576 let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
17577 herb.display_name = Some("Wild Herb".into());
17578 herb.category = Some("consumable".into());
17579 state.inventory_stacks = vec![sword, herb];
17580 state.inventory_tab = InventoryTab::OnPerson;
17581 state.inventory_filter = "sword".into();
17582
17583 let rows = state.inventory_selectable_rows();
17584 assert_eq!(rows.len(), 1);
17585 assert_eq!(rows[0].stack.template_id, "iron_sword");
17586
17587 let lines = state.inventory_browser_lines();
17588 assert!(lines.iter().any(|l| matches!(
17589 l,
17590 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
17591 )));
17592 assert!(!lines.iter().any(|l| matches!(
17593 l,
17594 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
17595 )));
17596 }
17597
17598 #[test]
17599 fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
17600 let mut state = sample_state();
17601 let id_a = uuid::Uuid::from_u128(0xa1);
17602 let id_b = uuid::Uuid::from_u128(0xb2);
17603 let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
17604 sword_a.display_name = Some("Iron Sword".into());
17605 sword_a.category = Some("weapon".into());
17606 sword_a.item_instance_id = Some(id_a);
17607 let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
17608 sword_b.display_name = Some("Iron Sword".into());
17609 sword_b.category = Some("weapon".into());
17610 sword_b.item_instance_id = Some(id_b);
17611 state.inventory_stacks = vec![sword_a, sword_b];
17612 state.inventory_tab = InventoryTab::OnPerson;
17613
17614 let lines = state.inventory_browser_lines();
17615 let items: Vec<_> = lines
17616 .iter()
17617 .filter_map(|l| match l {
17618 InventoryBrowserLine::Item {
17619 title,
17620 instance_tooltip,
17621 ..
17622 } => Some((title.clone(), instance_tooltip.clone())),
17623 _ => None,
17624 })
17625 .collect();
17626 assert_eq!(items.len(), 2);
17627 for (title, tip) in &items {
17628 assert!(
17629 !title.contains('#'),
17630 "title should not show instance suffix: {title}"
17631 );
17632 assert!(
17633 tip.is_some(),
17634 "two identical rows should expose instance on hover"
17635 );
17636 }
17637
17638 state.inventory_stacks.pop();
17639 let lines = state.inventory_browser_lines();
17640 let one = lines.iter().find_map(|l| match l {
17641 InventoryBrowserLine::Item {
17642 title,
17643 instance_tooltip,
17644 ..
17645 } => Some((title.clone(), instance_tooltip.clone())),
17646 _ => None,
17647 });
17648 let (title, tip) = one.expect("one sword row");
17649 assert!(!title.contains('#'));
17650 assert!(tip.is_none(), "single row should not need instance tooltip");
17651 }
17652
17653 #[test]
17654 fn inventory_person_rows_group_by_category() {
17655 let mut state = sample_state();
17656 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
17657 sword.category = Some("weapon".into());
17658 sword.display_name = Some("Iron Sword".into());
17659 let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
17660 ore.category = Some("resource".into());
17661 ore.display_name = Some("Iron Ore".into());
17662 let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
17663 potion.category = Some("consumable".into());
17664 potion.display_name = Some("Health Potion".into());
17665 state.inventory_stacks = vec![ore, potion, sword];
17666 state.inventory_tab = InventoryTab::OnPerson;
17667
17668 let lines = state.inventory_browser_lines();
17669 let labels: Vec<&str> = lines
17670 .iter()
17671 .filter_map(|l| match l {
17672 InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
17673 _ => None,
17674 })
17675 .collect();
17676 assert!(
17677 labels.iter().any(|s| s.contains("Weapons")),
17678 "expected Weapons group: {labels:?}"
17679 );
17680 assert!(labels.iter().any(|s| s.contains("Consumables")));
17681 assert!(labels.iter().any(|s| s.contains("Resources")));
17682
17683 let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
17684 let consumable_pos = labels
17685 .iter()
17686 .position(|s| s.contains("Consumables"))
17687 .unwrap();
17688 let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
17689 assert!(weapon_pos < consumable_pos);
17690 assert!(consumable_pos < resource_pos);
17691 }
17692
17693 #[test]
17694 fn inventory_tab_cycle_resets_selection() {
17695 let mut state = sample_state();
17696 state.inventory_tab = InventoryTab::OnPerson;
17697 state.inventory_menu_index = 3;
17698 state.inventory_tab = state.inventory_tab.cycle(true);
17699 assert_eq!(state.inventory_tab, InventoryTab::Nearby);
17700 assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
17702 assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
17703 assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
17704 assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
17705 }
17706
17707 #[test]
17708 fn parse_bank_copper_amount_blank_and_zero_mean_all() {
17709 assert_eq!(parse_bank_copper_amount(""), Some(0));
17710 assert_eq!(parse_bank_copper_amount(" "), Some(0));
17711 assert_eq!(parse_bank_copper_amount("0"), Some(0));
17712 assert_eq!(parse_bank_copper_amount("250"), Some(250));
17713 assert_eq!(parse_bank_copper_amount("nope"), None);
17714 }
17715
17716 #[test]
17717 fn parse_storage_quantity_blank_and_zero_mean_all() {
17718 assert_eq!(parse_storage_quantity(""), Some(None));
17719 assert_eq!(parse_storage_quantity(" "), Some(None));
17720 assert_eq!(parse_storage_quantity("0"), Some(None));
17721 assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
17722 assert_eq!(parse_storage_quantity("nope"), None);
17723 }
17724
17725 #[test]
17726 fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
17727 assert!(worker_error_is_hud_noise("path stuck — repathing"));
17728 assert!(worker_error_is_hud_noise(
17729 "path stuck — nudged clear, repathing"
17730 ));
17731 assert!(worker_error_is_hud_noise(
17732 "returned to lodging after path failures"
17733 ));
17734 assert!(!worker_error_is_hud_noise(
17736 "path stuck — no lodging to reset to"
17737 ));
17738 }
17739
17740 #[test]
17741 fn leaving_building_restores_outdoor_z_bands() {
17742 use flatland_protocol::{InteriorMapView, ZPlatformView};
17743
17744 let mut state = sample_state();
17745 state.z_platforms.clear();
17746 state.z_transitions.clear();
17747 state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
17748 state.interior_map = Some(InteriorMapView {
17749 building_id: "broker_hut".into(),
17750 blueprint_id: "broker_hut".into(),
17751 background_color: "#000".into(),
17752 default_floor_color: None,
17753 floor_height_m: 3.0,
17754 z_platforms: vec![ZPlatformView {
17755 id: "floor_0".into(),
17756 z: 0.0,
17757 x0: 0.0,
17758 y0: 0.0,
17759 x1: 8.0,
17760 y1: 8.0,
17761 }],
17762 z_transitions: vec![],
17763 rooms: vec![],
17764 room_doors: vec![],
17765 });
17766 state.sync_interior_map_context();
17767 assert_eq!(
17768 state.z_platforms.len(),
17769 1,
17770 "indoors installs interior platforms"
17771 );
17772 assert!(state.z_bands_outdoor_backup.is_some());
17773
17774 state.player.as_mut().unwrap().inside_building = None;
17775 state.sync_interior_map_context();
17776 assert!(
17777 state.z_platforms.is_empty(),
17778 "leaving must restore outdoor bands (empty), not leave interior platforms"
17779 );
17780 assert!(state.z_bands_outdoor_backup.is_none());
17781 assert!(state.interior_map.is_none());
17782 }
17783
17784 #[test]
17785 fn resource_node_route_label_prefers_friendly_label_with_suffix() {
17786 let node = ResourceNodeView {
17787 id: "crop-carrot-1_copy10".into(),
17788 label: "crop-carrot-1_copy10".into(),
17789 x: 0.0,
17790 y: 0.0,
17791 z: 0.0,
17792 item_template: "carrot".into(),
17793 state: ResourceNodeState::Available,
17794 blocking: false,
17795 blocking_radius_m: 0.5,
17796 harvest_off: false,
17797 tile_id: None,
17798 yaw: 0.0,
17799 pitch: 0.0,
17800 roll: 0.0,
17801 draw_scale: 1.0,
17802 sprite_mode: None,
17803 growth_progress: None,
17804 presentation_state: None,
17805 channel_start_tick: None,
17806 channel_end_tick: None,
17807 harvest_drop_templates: vec![],
17808 };
17809 let label = super::resource_node_route_label(&node);
17810 assert!(label.starts_with("Carrot ("), "got {label}");
17811 assert!(label.ends_with(')'), "got {label}");
17812
17813 let mut named = node;
17814 named.label = "Sweet Pad".into();
17815 named.id = "crop-carrot-a3f2b1c0".into();
17816 assert_eq!(super::resource_node_route_label(&named), "Sweet Pad (b1c0)");
17817 }
17818
17819 #[test]
17820 fn plot_public_label_uses_owner_zone_and_label() {
17821 let plot = flatland_protocol::PropertyPlotView {
17822 plot_id: uuid::Uuid::nil(),
17823 property_zone_id: "zone_a".into(),
17824 zone_label: Some("Starter Town East 1".into()),
17825 deed_instance_id: uuid::Uuid::nil(),
17826 x0: 0.0,
17827 y0: 0.0,
17828 x1: 4.0,
17829 y1: 4.0,
17830 upkeep_copper_per_day: 1,
17831 arrears_days: 0,
17832 is_mine: true,
17833 may_farm: true,
17834 purchase_basis_copper: 0,
17835 farm_public: false,
17836 public_tax_discount_bps: 0,
17837 farm_allow: vec![],
17838 owner_character_id: None,
17839 owner_label: Some("Madsin".into()),
17840 building_id: None,
17841 plot_code: "xyz1234a".into(),
17842 label: "Food Pad".into(),
17843 };
17844 assert_eq!(
17845 super::plot_public_label(&plot),
17846 "Madsin — Starter Town East 1 — Food Pad"
17847 );
17848 }
17849}