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);
119const WORKER_HIRE_PENDING_TIMEOUT: Duration = Duration::from_secs(15);
121
122#[derive(Debug, Clone, Default)]
124pub struct InventoryHint {
125 pub display_name: String,
126 pub category: String,
127 pub base_mass: Option<f32>,
128 pub base_volume: Option<f32>,
129 pub capacity_volume: Option<f32>,
130 pub stackable: bool,
131 pub listable: bool,
133 pub base_value_copper: Option<u32>,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct LoadoutHotbarChoice {
140 pub binding: String,
142 pub label: String,
144 pub meta: Option<String>,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
150pub enum RotationEditorMode {
151 #[default]
152 List,
153 EditSequence,
154 PickAbility,
155 EditLabel,
156}
157
158#[derive(Debug, Clone, Default)]
160pub struct RotationEditorState {
161 pub mode: RotationEditorMode,
162 pub list_index: usize,
163 pub ability_index: usize,
164 pub picker_index: usize,
165 pub draft: Option<RotationPreset>,
166 pub label_buffer: String,
167}
168
169impl RotationEditorState {
170 pub fn reset(&mut self) {
171 *self = Self::default();
172 }
173}
174
175pub const CONTAINER_RANGE_M: f32 = 3.0;
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum InventorySection {
185 Worn,
187 Person,
189 Nearby,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
195pub enum InventoryTab {
196 #[default]
197 OnPerson,
198 Nearby,
199}
200
201impl InventoryTab {
202 pub fn label(self) -> &'static str {
203 match self {
204 Self::OnPerson => "On person",
205 Self::Nearby => "Nearby storage",
206 }
207 }
208
209 pub fn cycle(self, forward: bool) -> Self {
210 match (self, forward) {
211 (Self::OnPerson, true) | (Self::OnPerson, false) => Self::Nearby,
212 (Self::Nearby, true) | (Self::Nearby, false) => Self::OnPerson,
213 }
214 }
215}
216
217pub const LIST_PAGE_SIZE: usize = 10;
219
220pub fn list_label_matches(haystack: &str, filter: &str) -> bool {
222 if filter.is_empty() {
223 return true;
224 }
225 haystack
226 .to_ascii_lowercase()
227 .contains(&filter.to_ascii_lowercase())
228}
229
230pub fn page_list_index(index: usize, pages: i32, len: usize) -> usize {
232 if len == 0 {
233 return 0;
234 }
235 let page = LIST_PAGE_SIZE as i32;
236 let next = index as i32 + pages * page;
237 next.clamp(0, (len as i32) - 1) as usize
238}
239
240pub fn step_filtered_index(
242 index: usize,
243 delta: i32,
244 len: usize,
245 pred: impl Fn(usize) -> bool,
246) -> usize {
247 if len == 0 {
248 return 0;
249 }
250 let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
251 if matching.is_empty() {
252 return index.min(len - 1);
253 }
254 let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
255 let next = (pos as i32 + delta).rem_euclid(matching.len() as i32) as usize;
256 matching[next]
257}
258
259pub fn page_filtered_index(
261 index: usize,
262 pages: i32,
263 len: usize,
264 pred: impl Fn(usize) -> bool,
265) -> usize {
266 if len == 0 {
267 return 0;
268 }
269 let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
270 if matching.is_empty() {
271 return index.min(len - 1);
272 }
273 let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
274 let next = page_list_index(pos, pages, matching.len());
275 matching[next]
276}
277
278pub fn inventory_category_group(category: &str) -> (&'static str, u8) {
280 match category {
281 "weapon" | "ammo" => ("Weapons", 0),
282 "armor" | "shield" | "offhand" => ("Armor", 1),
283 "consumable" => ("Consumables", 2),
284 "resource" | "harvest_node" | "seed" => ("Resources", 3),
285 "container" | "lodging" => ("Containers", 4),
286 "currency" | "key" => ("Currency & keys", 5),
287 "tool" | "misc" | "furniture" | "quest" | "document" => ("Gear & misc", 6),
288 _ => ("Other", 7),
289 }
290}
291
292pub fn category_default_listable(category: &str) -> bool {
294 !matches!(
295 category,
296 "currency" | "harvest_node" | "key" | "quest" | "document" | "lodging"
297 )
298}
299
300pub fn npc_market_dump_unit_estimate_copper(base_value: u32) -> Option<u32> {
302 if base_value == 0 {
303 return None;
304 }
305 let unit = ((base_value as f32) * 0.5).floor() as u32;
306 if unit == 0 {
307 return None;
308 }
309 Some(((unit as u64).saturating_mul(9500) / 10_000).max(1) as u32)
310}
311
312fn parse_bank_copper_amount(input: &str) -> Option<u64> {
314 let s = input.trim();
315 if s.is_empty() {
316 return Some(0);
317 }
318 s.parse::<u64>().ok()
319}
320
321fn parse_storage_quantity(input: &str) -> Option<Option<u32>> {
323 let s = input.trim();
324 if s.is_empty() || s == "0" {
325 return Some(None);
326 }
327 let n = s.parse::<u32>().ok()?;
328 if n == 0 {
329 return Some(None);
330 }
331 Some(Some(n))
332}
333
334fn storage_stack_label(stack: &flatland_protocol::ItemStack) -> String {
335 let name = stack
336 .display_name
337 .as_deref()
338 .unwrap_or(stack.template_id.as_str());
339 if stack.quantity > 1 {
340 format!("{name} ×{}", stack.quantity)
341 } else {
342 name.to_string()
343 }
344}
345
346pub fn body_slot_label(slot: BodySlot) -> &'static str {
349 match slot {
350 BodySlot::Head => "Head",
351 BodySlot::Chest => "Chest",
352 BodySlot::Forearms => "Forearms",
353 BodySlot::Legs => "Legs",
354 BodySlot::Feet => "Feet",
355 BodySlot::Cloak => "Cloak",
356 BodySlot::Back => "Back",
357 BodySlot::Waist => "Waist",
358 BodySlot::Earrings => "Earrings",
359 BodySlot::Necklace => "Necklace",
360 BodySlot::Eyeglasses => "Eyeglasses",
361 BodySlot::RingLeft1 => "Ring L1",
362 BodySlot::RingLeft2 => "Ring L2",
363 BodySlot::RingRight1 => "Ring R1",
364 BodySlot::RingRight2 => "Ring R2",
365 }
366}
367
368fn grant_target_matches_mode(stack: &flatland_protocol::ItemStack, mode: &str) -> bool {
369 let cat = stack.category.as_deref().unwrap_or("");
370 match mode {
371 "while_equipped" => {
372 stack.equip_slot.is_some()
373 || cat == "weapon"
374 || cat == "shield"
375 || cat == "offhand"
376 || cat == "armor"
377 }
378 _ => cat == "weapon" || cat == "ammo" || stack.props.contains_key("weapon_ability_id"),
379 }
380}
381
382fn grant_tags_match(stack: &flatland_protocol::ItemStack, grant_tags: &[&str]) -> bool {
383 if grant_tags.is_empty() {
384 return true;
385 }
386 let target_tags: Vec<&str> = stack
387 .props
388 .get("allowed_enchant_tags")
389 .map(|s| {
390 s.split(',')
391 .map(str::trim)
392 .filter(|t| !t.is_empty())
393 .collect()
394 })
395 .unwrap_or_default();
396 if target_tags.is_empty() {
397 return true;
398 }
399 grant_tags.iter().any(|t| target_tags.contains(t))
400}
401
402pub const DEFAULT_TICK_HZ: u32 = 30;
404
405pub fn format_binding_ttl(
407 binding: &flatland_protocol::ItemStatusBinding,
408 tick: u64,
409 tick_hz: u32,
410) -> String {
411 let Some(expires) = binding.expires_at_tick else {
412 return "permanent".into();
413 };
414 let hz = tick_hz.max(1) as f32;
415 let remaining = expires.saturating_sub(tick) as f32 / hz;
416 if remaining <= 0.0 {
417 return "expired".into();
418 }
419 if remaining >= 120.0 {
420 format!("{:.0}m left", remaining / 60.0)
421 } else if remaining >= 10.0 {
422 format!("{remaining:.0}s left")
423 } else {
424 format!("{remaining:.1}s left")
425 }
426}
427
428pub fn format_binding_mode(mode: flatland_protocol::ItemStatusBindingMode) -> &'static str {
429 match mode {
430 flatland_protocol::ItemStatusBindingMode::OnHit => "on hit",
431 flatland_protocol::ItemStatusBindingMode::WhileEquipped => "while equipped",
432 }
433}
434
435pub fn format_status_bindings_suffix(
437 bindings: &[flatland_protocol::ItemStatusBinding],
438 tick: u64,
439 tick_hz: u32,
440) -> String {
441 if bindings.is_empty() {
442 return String::new();
443 }
444 let parts: Vec<String> = bindings
445 .iter()
446 .map(|b| {
447 format!(
448 "{} ({}, {})",
449 b.effect_id,
450 format_binding_mode(b.mode),
451 format_binding_ttl(b, tick, tick_hz)
452 )
453 })
454 .collect();
455 format!(" · {}", parts.join("; "))
456}
457
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459pub enum EquipPaperdollRow {
460 Body { slot: BodySlot, filled: bool },
461 Mainhand { filled: bool },
462 Offhand { filled: bool, locked: bool },
463}
464
465pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
466 let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
467 .iter()
468 .map(|slot| EquipPaperdollRow::Body {
469 slot: *slot,
470 filled: state.worn.contains_key(slot),
471 })
472 .collect();
473 let two_hand = state.mainhand_hand_slots >= 2;
474 rows.push(EquipPaperdollRow::Mainhand {
475 filled: state.mainhand_template_id.is_some(),
476 });
477 rows.push(EquipPaperdollRow::Offhand {
478 filled: state.offhand_template_id.is_some(),
479 locked: two_hand,
480 });
481 rows
482}
483
484fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
485 for stack in &state.inventory_stacks {
486 let matches = stack
487 .equip_slot
488 .map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
489 .unwrap_or(false)
490 || guess_body_slot(&stack.template_id) == Some(slot);
491 if matches {
492 return stack.item_instance_id;
493 }
494 }
495 None
496}
497
498fn is_client_ring(slot: BodySlot) -> bool {
499 matches!(
500 slot,
501 BodySlot::RingLeft1 | BodySlot::RingLeft2 | BodySlot::RingRight1 | BodySlot::RingRight2
502 )
503}
504
505fn first_inventory_weapon(state: &GameState) -> Option<String> {
506 for stack in &state.inventory_stacks {
507 if stack.category.as_deref() == Some("weapon") {
508 return Some(stack.template_id.clone());
509 }
510 }
511 None
512}
513
514fn first_inventory_offhand(state: &GameState) -> Option<String> {
515 for stack in &state.inventory_stacks {
516 let cat = stack.category.as_deref().unwrap_or("");
517 if matches!(cat, "shield" | "offhand") {
518 return Some(stack.template_id.clone());
519 }
520 }
521 None
522}
523
524fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
527 if template_id.contains("backpack") {
528 Some(BodySlot::Back)
529 } else if template_id.contains("belt") {
530 Some(BodySlot::Waist)
531 } else if template_id.contains("cloak") || template_id.contains("cape") {
532 Some(BodySlot::Cloak)
533 } else if template_id.contains("cap")
534 || template_id.contains("hat")
535 || template_id.contains("helm")
536 {
537 Some(BodySlot::Head)
538 } else if template_id.contains("shirt")
539 || template_id.contains("robe")
540 || template_id.contains("vest")
541 || template_id.contains("chest")
542 || template_id.contains("jerkin")
543 {
544 Some(BodySlot::Chest)
545 } else if template_id.contains("sleeves")
546 || template_id.contains("gloves")
547 || template_id.contains("gauntlets")
548 {
549 Some(BodySlot::Forearms)
550 } else if template_id.contains("pants") || template_id.contains("leggings") {
551 Some(BodySlot::Legs)
552 } else if template_id.contains("boots") || template_id.contains("shoes") {
553 Some(BodySlot::Feet)
554 } else if template_id.contains("earring") {
555 Some(BodySlot::Earrings)
556 } else if template_id.contains("necklace") || template_id.contains("amulet") {
557 Some(BodySlot::Necklace)
558 } else if template_id.contains("glass")
559 || template_id.contains("spectacles")
560 || template_id.contains("goggles")
561 {
562 Some(BodySlot::Eyeglasses)
563 } else if template_id.contains("ring") {
564 Some(BodySlot::RingLeft1)
565 } else {
566 None
567 }
568}
569
570#[derive(Debug, Clone)]
572pub struct InventoryRow {
573 pub depth: usize,
574 pub stack: flatland_protocol::ItemStack,
575 pub from: flatland_protocol::InventoryLocation,
577 pub from_parent_instance_id: Option<uuid::Uuid>,
579 pub is_equip_shell: bool,
581 pub is_chest_shell: bool,
583 pub section: InventorySection,
584}
585
586#[derive(Debug, Clone)]
588pub struct InventoryRowView {
589 pub depth: usize,
590 pub text: String,
592 pub title: String,
594 pub mass_kg: Option<f32>,
595 pub volume: Option<(f32, f32)>,
596 pub instance_tooltip: Option<String>,
598}
599
600#[derive(Debug, Clone)]
602pub enum InventoryBrowserLine {
603 Section(String),
604 SlotLabel(String),
605 Hint(String),
606 Blank,
607 Item {
608 selectable_index: usize,
609 selected: bool,
610 depth: usize,
611 text: String,
612 title: String,
613 mass_kg: Option<f32>,
614 volume: Option<(f32, f32)>,
615 instance_tooltip: Option<String>,
616 },
617}
618
619#[derive(Debug, Clone, PartialEq, Eq, Default)]
621pub enum BankUiMode {
622 #[default]
623 Menu,
624 DepositAmount {
625 input: String,
626 },
627 WithdrawAmount {
628 input: String,
629 },
630 TransferName {
631 input: String,
632 },
633 TransferAmount {
634 to_name: String,
635 input: String,
636 },
637}
638
639#[derive(Debug, Clone, PartialEq, Eq, Default)]
641pub enum StorageUiMode {
642 #[default]
643 Menu,
644 StorePick { index: usize },
646 StoreAmount {
648 pick_index: usize,
649 item_instance_id: uuid::Uuid,
650 label: String,
651 max_qty: u32,
652 input: String,
653 },
654 TakePick { index: usize },
656 TakeAmount {
658 pick_index: usize,
659 item_instance_id: uuid::Uuid,
660 label: String,
661 max_qty: u32,
662 input: String,
663 },
664 ShipPick {
666 dest_building_id: String,
667 dest_label: String,
668 index: usize,
669 },
670 ShipAmount {
672 dest_building_id: String,
673 dest_label: String,
674 pick_index: usize,
675 item_instance_id: uuid::Uuid,
676 label: String,
677 max_qty: u32,
678 input: String,
679 },
680}
681
682#[derive(Debug, Clone, PartialEq, Eq)]
684pub enum MarketListSourceKind {
685 Person,
686 TownStorage { building_id: String },
687}
688
689#[derive(Debug, Clone, PartialEq, Eq, Default)]
691pub enum MarketUiMode {
692 #[default]
693 Browse,
694 ListSource { index: usize },
696 ListPick {
698 source: MarketListSourceKind,
699 index: usize,
700 },
701 ListAmount {
703 source: MarketListSourceKind,
704 pick_index: usize,
705 item_instance_id: uuid::Uuid,
706 template_id: String,
707 label: String,
708 max_qty: u32,
709 input: String,
710 },
711 ListPricingMode {
713 source: MarketListSourceKind,
714 pick_index: usize,
715 item_instance_id: uuid::Uuid,
716 template_id: String,
717 label: String,
718 quantity: Option<u32>,
719 max_qty: u32,
720 index: usize,
722 },
723 ListPrice {
725 source: MarketListSourceKind,
726 pick_index: usize,
727 item_instance_id: uuid::Uuid,
728 template_id: String,
729 label: String,
730 quantity: Option<u32>,
732 max_qty: u32,
733 input: String,
734 },
735}
736
737#[derive(Debug, Clone)]
739pub struct StoragePickOption {
740 pub item_instance_id: uuid::Uuid,
741 pub template_id: String,
742 pub label: String,
743 pub quantity: u32,
744 pub category: String,
746}
747
748#[derive(Debug, Clone)]
751pub struct NearbyContainer {
752 pub view: flatland_protocol::PlacedContainerView,
753 pub distance_m: f32,
754 pub rows: Vec<InventoryRow>,
755}
756
757#[derive(Debug, Clone)]
759pub struct KeychainEntry {
760 pub stack: flatland_protocol::ItemStack,
761 pub stowed: bool,
762}
763
764#[derive(Debug, Clone)]
766pub struct MoveOption {
767 pub label: String,
768 pub kind: MoveOptionKind,
769}
770
771#[derive(Debug, Clone, PartialEq)]
772pub enum MoveOptionKind {
773 Move {
774 location: flatland_protocol::InventoryLocation,
775 parent_instance_id: Option<uuid::Uuid>,
776 },
777 PickupPlaced {
779 container_id: String,
780 nest_location: flatland_protocol::InventoryLocation,
781 nest_parent_instance_id: Option<uuid::Uuid>,
782 },
783 RelocatePlaced {
785 container_id: String,
786 },
787 Use,
789 GrantApply,
791 Drop,
792 SellPlotToCrown {
794 plot_id: uuid::Uuid,
795 },
796 Cancel,
797}
798
799#[derive(Debug, Clone, PartialEq)]
801pub enum FarmAccessRow {
802 PublicToggle,
803 PublicDiscount,
804 AllowRemove {
805 character_id: uuid::Uuid,
806 label: String,
807 tax_discount_bps: u32,
808 },
809 NearbyAdd {
810 name: String,
811 },
812}
813
814#[derive(Debug, Clone)]
816pub struct GrantTargetPicker {
817 pub grant_instance_id: uuid::Uuid,
818 pub grant_label: String,
819 pub effect_id: String,
820 pub mode: String,
821 pub options: Vec<GrantTargetOption>,
822 pub filter: String,
823 pub filter_focused: bool,
824}
825
826#[derive(Debug, Clone)]
827pub struct GrantTargetOption {
828 pub label: String,
829 pub target_instance_id: uuid::Uuid,
830}
831
832#[derive(Debug, Clone)]
834pub struct MovePicker {
835 pub item_instance_id: uuid::Uuid,
836 pub from: flatland_protocol::InventoryLocation,
837 pub item_label: String,
838 pub template_id: String,
839 pub stack_quantity: u32,
840 pub quantity: u32,
841 pub options: Vec<MoveOption>,
842 pub filter: String,
843 pub filter_focused: bool,
844}
845
846#[derive(Debug, Clone)]
848pub struct DestroyPicker {
849 pub item_instance_id: uuid::Uuid,
850 pub from: flatland_protocol::InventoryLocation,
851 pub item_label: String,
852 pub stack_quantity: u32,
853 pub quantity: u32,
854}
855
856#[derive(Debug, Clone)]
858pub struct WorkerGiveOption {
859 pub item_instance_id: uuid::Uuid,
860 pub label: String,
861 pub quantity: u32,
862 pub template_id: String,
863}
864
865#[derive(Debug, Clone)]
867pub struct WorkerGivePicker {
868 pub worker_instance_id: String,
869 pub worker_label: String,
870 pub options: Vec<WorkerGiveOption>,
871}
872
873#[derive(Debug, Clone)]
875pub struct WorkerGiveTargetOption {
876 pub instance_id: String,
877 pub label: String,
878 pub distance_m: f32,
879}
880
881#[derive(Debug, Clone)]
883pub struct WorkerGiveTargetPicker {
884 pub item_instance_id: uuid::Uuid,
885 pub item_label: String,
886 pub quantity: Option<u32>,
887 pub options: Vec<WorkerGiveTargetOption>,
888}
889
890#[derive(Debug, Clone)]
892pub struct WorkerTakePicker {
893 pub worker_instance_id: String,
894 pub worker_label: String,
895 pub options: Vec<WorkerGiveOption>,
896 pub quantity: u32,
898}
899
900pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
902
903#[derive(Debug, Clone)]
905pub struct WorkerTeachOption {
906 pub blueprint_id: String,
907 pub label: String,
908 pub cost_copper: u64,
909 pub min_level: u32,
910 pub worker_level: u32,
911 pub can_afford: bool,
912 pub level_ok: bool,
913}
914
915#[derive(Debug, Clone)]
917pub struct WorkerTeachPicker {
918 pub worker_instance_id: String,
919 pub worker_label: String,
920 pub worker_level: u32,
921 pub options: Vec<WorkerTeachOption>,
922}
923
924#[derive(Debug, Clone)]
926pub struct WorkerDismissConfirmation {
927 pub worker_instance_id: String,
928 pub worker_label: String,
929}
930
931#[derive(Debug, Clone, Default)]
934pub struct StickyWorkerStep {
935 shown: String,
936 pending: String,
937 pending_since: Option<Instant>,
938}
939
940impl StickyWorkerStep {
941 fn from_label(label: String) -> Self {
942 Self {
943 shown: label.clone(),
944 pending: label,
945 pending_since: Some(Instant::now()),
946 }
947 }
948
949 fn observe(&mut self, label: &str, now: Instant) {
950 let pending_since = self.pending_since.unwrap_or(now);
951 if label == self.pending {
952 if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD {
953 self.shown = self.pending.clone();
954 }
955 return;
956 }
957 self.pending = label.to_string();
958 self.pending_since = Some(now);
959 if self.shown.is_empty() {
961 self.shown = self.pending.clone();
962 }
963 }
964}
965
966#[derive(Debug, Clone, Default)]
969pub struct StickyWorkerError {
970 message: String,
971 last_seen: Option<Instant>,
972}
973
974impl StickyWorkerError {
975 fn observe(&mut self, err: Option<&str>, now: Instant) {
976 if let Some(e) = err {
977 if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
978 self.message = e.to_string();
979 self.last_seen = Some(now);
980 }
981 return;
982 }
983 if let Some(seen) = self.last_seen {
984 if now.duration_since(seen) > WORKER_ERROR_HOLD {
985 self.message.clear();
986 self.last_seen = None;
987 }
988 }
989 }
990
991 pub fn shown(&self, now: Instant) -> Option<&str> {
992 if self.message.is_empty() {
993 return None;
994 }
995 let seen = self.last_seen?;
996 if now.duration_since(seen) > WORKER_ERROR_HOLD {
997 return None;
998 }
999 Some(self.message.as_str())
1000 }
1001}
1002
1003pub fn worker_attention_line(state: &GameState) -> Option<String> {
1006 use flatland_protocol::WorkerStateView;
1007 let now = Instant::now();
1008 for w in &state.hired_workers {
1009 if matches!(w.state, WorkerStateView::Strike) {
1010 return Some(format!(
1011 "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
1012 w.label
1013 ));
1014 }
1015 let sticky = state
1016 .worker_error_display
1017 .get(&w.instance_id)
1018 .and_then(|s| s.shown(now))
1019 .filter(|e| !worker_error_is_hud_noise(e));
1020 let live = w
1021 .last_error
1022 .as_deref()
1023 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e));
1024 if let Some(err) = sticky.or(live) {
1025 if let Some(hint) = w
1026 .issue_hint
1027 .as_deref()
1028 .filter(|h| !h.is_empty())
1029 .or_else(|| worker_issue_fix_hint(err))
1030 {
1031 return Some(format!("Worker {}: {err} — {hint}", w.label));
1032 }
1033 return Some(format!("Worker {}: {err}", w.label));
1034 }
1035 if let Some(hint) = w.issue_hint.as_deref().filter(|h| !h.is_empty()) {
1037 return Some(format!("Worker {}: {hint}", w.label));
1038 }
1039 }
1040 None
1041}
1042
1043pub fn worker_issue_fix_hint(err: &str) -> Option<&'static str> {
1045 let e = err.to_ascii_lowercase();
1046 if e.contains("missing")
1047 || e.contains("container not found")
1048 || e.contains("lodging container not found")
1049 {
1050 return Some("edit route (e): replace the missing chest/bed");
1051 }
1052 if e.contains("stranded at interior") || e.contains("interior map coords") {
1053 return Some("recovered — continuing route");
1054 }
1055 if e.contains("stuck inside")
1056 || e.contains("sent outside")
1057 || e.contains("sent to door")
1058 || e.contains("left building")
1059 {
1060 return Some("auto-exit for outdoor work — restart after update if it still loops");
1061 }
1062 if e.contains("collapsed") || e.contains("need food") {
1063 return Some("stock lodging bed with food and drink");
1064 }
1065 if e.contains("overburdened") {
1066 return Some("add a deposit/sell stop, or empty their pack");
1067 }
1068 if e.contains("need a hoe") || e.contains("need a dibber") {
1069 return Some("give them the tool or withdraw it on the route");
1070 }
1071 None
1072}
1073
1074pub fn worker_error_is_transient(err: &str) -> bool {
1076 let e = err.to_ascii_lowercase();
1077 e.contains("continuing route")
1078 || e.contains("storage full")
1079 || e.starts_with("nothing to withdraw")
1080}
1081
1082pub fn worker_error_is_hud_noise(err: &str) -> bool {
1085 let e = err.to_ascii_lowercase();
1086 e.contains("returned to lodging after path")
1087 || e.contains("path failure")
1088 || e.contains("no path to")
1089 || e.contains("pathfinding")
1090 || e.contains("repathing")
1092 || e.contains("nudged clear")
1093 || e.contains("auto-recovery")
1095 || e.contains("stranded at interior map coords")
1096}
1097
1098#[derive(Debug, Clone)]
1100pub struct PendingWorkerJobAck {
1101 pub seq: u32,
1102 pub worker_instance_id: String,
1103 pub worker_label: String,
1104 pub idle: bool,
1105 pub stop_count: usize,
1106 pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1107 pub prev_mode: flatland_protocol::WorkerModeView,
1108 pub prev_step_label: String,
1109 pub prev_last_error: Option<String>,
1110}
1111
1112fn push_inventory_rows(
1113 rows: &mut Vec<InventoryRow>,
1114 depth: usize,
1115 stack: &flatland_protocol::ItemStack,
1116 from: &flatland_protocol::InventoryLocation,
1117 from_parent_instance_id: Option<uuid::Uuid>,
1118 section: InventorySection,
1119) {
1120 push_inventory_rows_filtered(
1121 rows,
1122 depth,
1123 stack,
1124 from,
1125 from_parent_instance_id,
1126 section,
1127 "",
1128 );
1129}
1130
1131fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1132 if filter.is_empty() {
1133 return true;
1134 }
1135 let f = filter.to_ascii_lowercase();
1136 let name = stack
1137 .display_name
1138 .as_deref()
1139 .unwrap_or("")
1140 .to_ascii_lowercase();
1141 let tid = stack.template_id.to_ascii_lowercase();
1142 name.contains(&f)
1143 || tid.contains(&f)
1144 || stack
1145 .contents
1146 .iter()
1147 .any(|c| stack_matches_filter(c, filter))
1148}
1149
1150fn push_inventory_rows_filtered(
1151 rows: &mut Vec<InventoryRow>,
1152 depth: usize,
1153 stack: &flatland_protocol::ItemStack,
1154 from: &flatland_protocol::InventoryLocation,
1155 from_parent_instance_id: Option<uuid::Uuid>,
1156 section: InventorySection,
1157 filter: &str,
1158) {
1159 if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1160 return;
1161 }
1162 let self_hit = filter.is_empty() || {
1163 let f = filter.to_ascii_lowercase();
1164 let name = stack
1165 .display_name
1166 .as_deref()
1167 .unwrap_or("")
1168 .to_ascii_lowercase();
1169 let tid = stack.template_id.to_ascii_lowercase();
1170 name.contains(&f) || tid.contains(&f)
1171 };
1172 rows.push(InventoryRow {
1173 depth,
1174 stack: stack.clone(),
1175 from: from.clone(),
1176 from_parent_instance_id,
1177 is_equip_shell: false,
1178 is_chest_shell: false,
1179 section,
1180 });
1181 for child in &stack.contents {
1182 if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1183 push_inventory_rows_filtered(
1184 rows,
1185 depth + 1,
1186 child,
1187 from,
1188 stack.item_instance_id,
1189 section,
1190 if self_hit { "" } else { filter },
1191 );
1192 }
1193 }
1194}
1195
1196#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1197pub enum ShopTab {
1198 #[default]
1199 Buy,
1200 Sell,
1201}
1202
1203#[derive(Debug, Clone)]
1204pub struct NpcChatState {
1205 pub npc_id: String,
1206 pub npc_label: String,
1207 pub lines: Vec<String>,
1208 pub input: String,
1209 pub pending: bool,
1210 pub talk_depth: flatland_protocol::NpcTalkDepth,
1211 pub trade_allowed: bool,
1212 pub banner: Option<String>,
1213 pub suggested_topics: Vec<String>,
1214}
1215
1216impl Default for NpcChatState {
1217 fn default() -> Self {
1218 Self {
1219 npc_id: String::new(),
1220 npc_label: String::new(),
1221 lines: Vec::new(),
1222 input: String::new(),
1223 pending: false,
1224 talk_depth: flatland_protocol::NpcTalkDepth::Full,
1225 trade_allowed: true,
1226 banner: None,
1227 suggested_topics: Vec::new(),
1228 }
1229 }
1230}
1231
1232pub fn npc_world_xy(state: &GameState, npc: &NpcView) -> (f32, f32) {
1234 npc.entity_id
1235 .and_then(|eid| state.entities.iter().find(|e| e.id == eid))
1236 .map(|e| (e.transform.position.x, e.transform.position.y))
1237 .unwrap_or((npc.x, npc.y))
1238}
1239
1240#[derive(Debug, Clone)]
1241pub struct GameState {
1242 pub session_id: SessionId,
1243 pub entity_id: EntityId,
1244 pub character_id: Option<uuid::Uuid>,
1246 pub tick: Tick,
1247 pub chunk_rev: u64,
1248 pub content_rev: u64,
1249 pub publish_rev: u64,
1250 pub entities: Vec<EntityState>,
1251 pub player: Option<EntityState>,
1252 pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1253 pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1254 pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1255 pub buildings: Vec<BuildingView>,
1256 pub doors: Vec<DoorView>,
1257 pub interior_map: Option<InteriorMapView>,
1258 pub npcs: Vec<NpcView>,
1259 pub blueprints: Vec<BlueprintView>,
1260 pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1262 pub world_x0: f32,
1264 pub world_y0: f32,
1265 pub world_width_m: f32,
1266 pub world_height_m: f32,
1267 pub terrain_zones: Vec<TerrainZoneView>,
1268 pub z_platforms: Vec<ZPlatformView>,
1269 pub z_transitions: Vec<ZTransitionView>,
1270 #[doc(hidden)]
1273 pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1274 pub world_clock: flatland_protocol::WorldClock,
1275 pub inventory: std::collections::HashMap<String, u32>,
1276 pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1277 pub logs: VecDeque<String>,
1278 pub intents_sent: u64,
1279 pub ticks_received: u64,
1280 pub connected: bool,
1281 pub disconnect_reason: Option<String>,
1282 pub show_stats: bool,
1283 pub hud_log_hidden: bool,
1285 pub show_equip_menu: bool,
1286 pub equip_menu_index: usize,
1287 pub show_craft_menu: bool,
1288 pub craft_menu_index: usize,
1289 pub craft_batch_quantity: u32,
1291 pub show_plot_build_menu: bool,
1293 pub plot_build_focus_wall: bool,
1295 pub plot_build_wall_index: usize,
1296 pub plot_build_roof_index: usize,
1297 pub show_shop_menu: bool,
1298 pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1299 pub bank_panel: Option<flatland_protocol::BankPanel>,
1300 pub bank_menu_index: usize,
1301 pub bank_ui_mode: BankUiMode,
1302 pub storage_panel: Option<flatland_protocol::StoragePanel>,
1303 pub market_panel: Option<flatland_protocol::MarketPanel>,
1304 pub market_menu_index: usize,
1306 pub market_filter: String,
1308 pub market_filter_focused: bool,
1309 pub market_category_filter: Option<&'static str>,
1311 pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1313 pub market_ui_mode: MarketUiMode,
1314 pub storage_menu_index: usize,
1315 pub storage_ui_mode: StorageUiMode,
1316 pub shop_tab: ShopTab,
1317 pub shop_menu_index: usize,
1318 pub shop_quantity: u32,
1319 pub shop_trade_log: VecDeque<String>,
1321 pub show_npc_verb_menu: bool,
1322 pub npc_verb_target: Option<String>,
1323 pub npc_verb_index: usize,
1324 pub player_verbs: crate::social::PlayerVerbState,
1326 pub social_chat: crate::social::SocialChatState,
1327 pub trade_ui: crate::social::TradeUiState,
1328 pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1329 pub show_npc_chat: bool,
1330 pub npc_chat: Option<NpcChatState>,
1331 pub show_inventory_menu: bool,
1332 pub inventory_menu_index: usize,
1333 pub inventory_tab: InventoryTab,
1334 pub inventory_filter: String,
1335 pub inventory_filter_focused: bool,
1336 pub show_move_picker: bool,
1337 pub move_picker_index: usize,
1338 pub move_picker: Option<MovePicker>,
1339 pub show_grant_picker: bool,
1340 pub grant_picker_index: usize,
1341 pub grant_picker: Option<GrantTargetPicker>,
1342 pub show_destroy_picker: bool,
1343 pub destroy_confirm_pending: bool,
1344 pub destroy_picker: Option<DestroyPicker>,
1345 pub show_rename_prompt: bool,
1347 pub rename_plot_id: Option<uuid::Uuid>,
1349 pub highlighted_plot_id: Option<uuid::Uuid>,
1351 pub show_worker_rename: bool,
1353 pub rename_buffer: String,
1354 pub combat_target: Option<EntityId>,
1356 pub combat_target_label: Option<String>,
1357 pub ground_target: Option<(f32, f32, f32)>,
1360 pub combat_fx: Vec<flatland_protocol::CombatFx>,
1362 pub ground_hazards: Vec<flatland_protocol::GroundHazardView>,
1364 pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1366 pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1368 pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1370 pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1372 pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1374 pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1376 pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1378 pub claim_mode: Option<ClaimModeState>,
1380 pub relocate_mode: Option<RelocateModeState>,
1382 pub sell_plot_confirm: Option<uuid::Uuid>,
1384 pub sell_plot_armed_at: Option<Instant>,
1386 pub show_plant_menu: bool,
1388 pub plant_menu_index: usize,
1389 pub show_farm_access: bool,
1391 pub farm_access_name_draft: String,
1393 pub farm_access_discount_bps: u32,
1395 pub farm_access_index: usize,
1397 pub plant_quantity: u32,
1398 pub in_combat: bool,
1399 pub auto_attack: bool,
1400 pub combat_has_los: bool,
1401 pub attack_cd_ticks: u64,
1402 pub gcd_ticks: u64,
1403 pub weapon_ability_id: String,
1404 pub mainhand_template_id: Option<String>,
1405 pub mainhand_label: Option<String>,
1406 pub mainhand_instance_id: Option<uuid::Uuid>,
1407 pub offhand_template_id: Option<String>,
1408 pub offhand_label: Option<String>,
1409 pub offhand_instance_id: Option<uuid::Uuid>,
1410 pub mainhand_hand_slots: u8,
1411 pub defense: Option<flatland_protocol::DefenseHud>,
1412 pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1414 pub carry_mass: f32,
1415 pub carry_mass_max: f32,
1416 pub encumbrance: flatland_protocol::EncumbranceState,
1417 pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1419 pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1421 pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1423 pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1425 pub combat_target_detail: Option<CombatTargetHud>,
1426 pub cast_progress: Option<CastProgressHud>,
1427 pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1429 pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1431 pub ability_cooldowns: Vec<AbilityCooldownHud>,
1432 pub blocking_active: bool,
1433 pub max_target_slots: u8,
1434 pub combat_slots: Vec<CombatSlotHud>,
1435 pub rotation_presets: Vec<RotationPreset>,
1436 pub known_abilities: Vec<String>,
1438 pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1440 pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1442 pub hotbar: Vec<Option<String>>,
1444 pub max_abilities_per_rotation: u8,
1446 pub show_loadout_menu: bool,
1447 pub show_keychain_menu: bool,
1448 pub keychain_menu_index: usize,
1449 pub show_rotation_editor: bool,
1450 pub loadout_menu_index: usize,
1452 pub loadout_hotbar_slot: u8,
1454 pub loadout_ability_index: usize,
1456 pub loadout_focus_presets: bool,
1458 pub rotation_editor: RotationEditorState,
1459 pub harvest_in_progress: bool,
1461 pub harvest_started_at: Option<Instant>,
1463 pub pending_craft_ack: Option<(u32, String, u32)>,
1465 pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1466 pub interactables: Vec<flatland_protocol::InteractableView>,
1467 pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1468 pub career: Option<flatland_protocol::PlayerCareerView>,
1469 pub character_sheet_tab: CharacterSheetTab,
1470 pub ledger_period: LedgerPeriod,
1471 pub show_quest_offer: bool,
1472 pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1473 pub show_quest_menu: bool,
1474 pub quest_menu_index: usize,
1475 pub quest_withdraw_confirm: bool,
1476 pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1477 pub show_workers_menu: bool,
1478 pub workers_menu_index: usize,
1479 pub worker_dismiss_confirmation: Option<WorkerDismissConfirmation>,
1480 pub workers_menu_compact: bool,
1482 pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1485 pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1487 pub worker_health_ring_until: BTreeMap<EntityId, Instant>,
1489 pub pending_worker_hire_since: Option<Instant>,
1491 pub show_worker_give_picker: bool,
1493 pub worker_give_picker_index: usize,
1494 pub worker_give_picker: Option<WorkerGivePicker>,
1495 pub show_worker_give_target_picker: bool,
1497 pub worker_give_target_picker_index: usize,
1498 pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1499 pub show_worker_take_picker: bool,
1501 pub worker_take_picker_index: usize,
1502 pub worker_take_picker: Option<WorkerTakePicker>,
1503 pub show_worker_teach_picker: bool,
1505 pub worker_teach_picker_index: usize,
1506 pub worker_teach_picker: Option<WorkerTeachPicker>,
1507 pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1509 pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1511 pub attending_worker_instance_id: Option<String>,
1513 pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1515}
1516
1517impl GameState {
1518 pub fn push_log(&mut self, line: impl Into<String>) {
1519 self.logs.push_back(line.into());
1520 while self.logs.len() > MAX_LOG_LINES {
1521 self.logs.pop_front();
1522 }
1523 }
1524
1525 pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1526 self.shop_trade_log.push_back(line.into());
1527 while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1528 self.shop_trade_log.pop_front();
1529 }
1530 }
1531
1532 pub fn clear_shop_trade_log(&mut self) {
1533 self.shop_trade_log.clear();
1534 }
1535
1536 fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1537 if !self.show_shop_menu {
1538 return;
1539 }
1540 let msg = notice.message.trim();
1541 if msg.is_empty() {
1542 return;
1543 }
1544 if notice.coins_delta != 0
1545 || msg.starts_with("Bought ")
1546 || msg.starts_with("Sold ")
1547 || msg.contains("taught you how to craft")
1548 || msg.starts_with("need ")
1549 {
1550 self.push_shop_trade_log(msg);
1551 }
1552 }
1553
1554 pub fn is_alive(&self) -> bool {
1555 self.player
1556 .as_ref()
1557 .and_then(|p| p.vitals)
1558 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1559 .unwrap_or(true)
1560 }
1561
1562 pub fn push_audio(&mut self, cue: crate::social::AudioCue) {
1563 self.social_chat.push_cue(cue);
1564 }
1565
1566 fn sync_gameplay_audio(&mut self) {
1568 use crate::social::AudioCue;
1569 use flatland_protocol::PrimaryAttributes;
1570
1571 let alive = self.is_alive();
1572 let casting = self.cast_progress.is_some();
1573 let telegraph = self.focus_attack_telegraph_active();
1574 let in_aoe = self.player_inside_spatial_telegraph();
1575 let quest_sig = self.quest_audio_signature();
1576 let entity_id = self.entity_id;
1577 let char_level = self
1578 .player
1579 .as_ref()
1580 .and_then(|p| p.attributes)
1581 .map(|a| {
1582 PrimaryAttributes::display(a.strength)
1583 .saturating_add(PrimaryAttributes::display(a.dexterity))
1584 .saturating_add(PrimaryAttributes::display(a.intelligence))
1585 .saturating_add(PrimaryAttributes::display(a.stamina))
1586 .saturating_add(PrimaryAttributes::display(a.vitality))
1587 .saturating_add(PrimaryAttributes::display(a.wisdom))
1588 .saturating_add(PrimaryAttributes::display(a.charisma))
1589 })
1590 .unwrap_or(0);
1591
1592 let fx_ids: Vec<u64> = self.combat_fx.iter().map(|fx| fx.id).collect();
1593 let mut hit_cues = Vec::new();
1594 {
1595 let seen = &self.social_chat.audio_seen_fx_ids;
1596 for fx in &self.combat_fx {
1597 if seen.contains(&fx.id) {
1598 continue;
1599 }
1600 let Some(hit) = fx.hits.iter().find(|h| h.entity_id == entity_id) else {
1601 continue;
1602 };
1603 if hit.outcome == CombatFxHitOutcome::Blocked {
1604 hit_cues.push(AudioCue::CombatBlock);
1605 } else {
1606 let heavy = matches!(
1607 fx.kind,
1608 CombatFxKind::Sphere | CombatFxKind::Cone | CombatFxKind::Beam
1609 );
1610 hit_cues.push(if heavy {
1611 AudioCue::CombatHitHeavy
1612 } else {
1613 AudioCue::CombatHitLight
1614 });
1615 }
1616 }
1617 }
1618
1619 let audio = &mut self.social_chat;
1620 if !audio.audio_bootstrapped {
1621 audio.audio_was_alive = alive;
1622 audio.audio_was_casting = casting;
1623 audio.audio_had_target_telegraph = telegraph;
1624 audio.audio_was_in_aoe = in_aoe;
1625 audio.audio_quest_sig = quest_sig;
1626 audio.audio_char_level = char_level;
1627 audio.audio_seen_fx_ids = fx_ids;
1628 audio.audio_bootstrapped = true;
1629 return;
1630 }
1631
1632 if telegraph && !audio.audio_had_target_telegraph {
1633 audio.push_cue(AudioCue::CombatTelegraphStart);
1634 } else if !telegraph && audio.audio_had_target_telegraph {
1635 audio.push_cue(AudioCue::CombatTelegraphImpact);
1636 }
1637 audio.audio_had_target_telegraph = telegraph;
1638
1639 if in_aoe && !audio.audio_was_in_aoe {
1640 audio.push_cue(AudioCue::CombatAoeWarn);
1641 }
1642 audio.audio_was_in_aoe = in_aoe;
1643
1644 if casting && !audio.audio_was_casting {
1645 audio.push_cue(AudioCue::AbilityCastSelf);
1646 }
1647 audio.audio_was_casting = casting;
1648
1649 if !alive && audio.audio_was_alive {
1650 audio.push_cue(AudioCue::PlayerDeath);
1651 }
1652 audio.audio_was_alive = alive;
1653
1654 if quest_sig != audio.audio_quest_sig && audio.audio_quest_sig != 0 {
1655 audio.push_cue(AudioCue::QuestUpdate);
1656 }
1657 audio.audio_quest_sig = quest_sig;
1658
1659 if char_level > audio.audio_char_level && audio.audio_char_level > 0 {
1660 audio.push_cue(AudioCue::LevelUp);
1661 }
1662 audio.audio_char_level = char_level;
1663
1664 for cue in hit_cues {
1665 audio.push_cue(cue);
1666 }
1667 audio.audio_seen_fx_ids = fx_ids;
1668 }
1669
1670 fn focus_attack_telegraph_active(&self) -> bool {
1671 let Some(tid) = self.combat_target else {
1672 return false;
1673 };
1674 self.entities
1675 .iter()
1676 .find(|e| e.id == tid)
1677 .map(|e| {
1678 e.combat_cues.iter().any(|c| {
1679 matches!(c.kind, CombatCueKind::AttackTelegraph) && c.until_tick > self.tick
1680 })
1681 })
1682 .unwrap_or(false)
1683 }
1684
1685 fn player_inside_spatial_telegraph(&self) -> bool {
1686 let (px, py) = self.player_position();
1687 for e in &self.entities {
1688 for cue in &e.combat_cues {
1689 if !matches!(cue.kind, CombatCueKind::AttackTelegraph)
1690 || cue.until_tick <= self.tick
1691 {
1692 continue;
1693 }
1694 let Some(kind) = cue.telegraph_kind else {
1695 continue;
1696 };
1697 let (ox, oy) = match (cue.origin_x, cue.origin_y) {
1698 (Some(x), Some(y)) => (x, y),
1699 _ => continue,
1700 };
1701 match kind {
1702 CombatFxKind::Sphere => {
1703 let r = cue.radius_m.unwrap_or(1.0);
1704 let dx = px - ox;
1705 let dy = py - oy;
1706 if dx * dx + dy * dy <= r * r {
1707 return true;
1708 }
1709 }
1710 CombatFxKind::Cone | CombatFxKind::MeleeArc => {
1711 let reach = cue.reach_m.unwrap_or(2.0);
1712 let yaw = cue.yaw.unwrap_or(0.0);
1713 let arc = cue.arc_deg.unwrap_or(90.0).to_radians();
1714 let dx = px - ox;
1715 let dy = py - oy;
1716 let dist = (dx * dx + dy * dy).sqrt();
1717 if dist > reach || dist < 0.05 {
1718 continue;
1719 }
1720 let ang = dx.atan2(dy);
1721 let mut delta = ang - yaw;
1722 while delta > std::f32::consts::PI {
1723 delta -= std::f32::consts::TAU;
1724 }
1725 while delta < -std::f32::consts::PI {
1726 delta += std::f32::consts::TAU;
1727 }
1728 if delta.abs() <= arc * 0.5 {
1729 return true;
1730 }
1731 }
1732 _ => {}
1733 }
1734 }
1735 }
1736 false
1737 }
1738
1739 fn quest_audio_signature(&self) -> u64 {
1740 use std::collections::hash_map::DefaultHasher;
1741 use std::hash::{Hash, Hasher};
1742 let mut h = DefaultHasher::new();
1743 for q in &self.quest_log {
1744 q.quest_id.hash(&mut h);
1745 format!("{:?}", q.status).hash(&mut h);
1746 q.current_step_id.hash(&mut h);
1747 for o in &q.objectives {
1748 o.done.hash(&mut h);
1749 o.current.hash(&mut h);
1750 }
1751 }
1752 h.finish()
1753 }
1754
1755 pub fn npc_verb_options(&self) -> Vec<&'static str> {
1757 let Some(ref id) = self.npc_verb_target else {
1758 return vec![];
1759 };
1760 let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1761 return self.with_quest_turn_in(id, vec!["Talk"]);
1762 };
1763 let role = npc.role.as_str();
1764 let rest = if Self::npc_role_is_bank(role) {
1765 vec!["Bank", "Talk"]
1766 } else if Self::npc_role_is_storage(role) {
1767 vec!["Storage", "Talk"]
1768 } else if Self::npc_role_is_market(role) {
1769 vec!["Market", "Talk"]
1770 } else if npc.can_trade || Self::npc_role_can_trade(role) {
1771 vec!["Talk", "Trade"]
1772 } else {
1773 vec!["Talk"]
1774 };
1775 self.with_quest_turn_in(id, rest)
1776 }
1777
1778 fn with_quest_turn_in(&self, npc_id: &str, rest: Vec<&'static str>) -> Vec<&'static str> {
1779 if self.npc_has_pending_give(npc_id) {
1780 let mut opts = vec!["Turn in"];
1781 opts.extend(rest);
1782 opts
1783 } else {
1784 rest
1785 }
1786 }
1787
1788 fn npc_has_pending_give(&self, npc_id: &str) -> bool {
1789 self.quest_log.iter().any(|q| {
1790 q.status == flatland_protocol::QuestStatusView::Active
1791 && q.objectives.iter().any(|o| {
1792 !o.done && o.kind == "give_item" && o.npc_ref.as_deref() == Some(npc_id)
1793 })
1794 })
1795 }
1796
1797 fn count_inventory_template(&self, template: &str) -> u32 {
1798 self.inventory_stacks
1799 .iter()
1800 .filter(|s| s.template_id == template)
1801 .map(|s| s.quantity)
1802 .sum()
1803 }
1804
1805 fn npc_role_can_trade(role: &str) -> bool {
1806 matches!(role, "broker" | "cook" | "farmer" | "merchant")
1807 }
1808
1809 fn npc_role_is_bank(role: &str) -> bool {
1810 role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1811 }
1812
1813 fn npc_role_is_storage(role: &str) -> bool {
1814 role.eq_ignore_ascii_case("storage_manager")
1815 }
1816
1817 fn npc_role_is_market(role: &str) -> bool {
1818 role.eq_ignore_ascii_case("market_clerk")
1819 }
1820
1821 pub fn bank_menu_options(&self) -> Vec<&'static str> {
1822 vec![
1823 "Deposit…",
1824 "Withdraw…",
1825 "Deposit all",
1826 "Withdraw all",
1827 "Transfer…",
1828 ]
1829 }
1830
1831 pub fn storage_menu_options(&self) -> Vec<String> {
1832 let mut opts = vec!["Store…".into(), "Take…".into()];
1833 if let Some(panel) = &self.storage_panel {
1834 for dest in &panel.ship_destinations {
1835 opts.push(format!(
1836 "Ship → {} ({} cp / {} ticks)",
1837 dest.label, dest.fee_copper, dest.travel_ticks
1838 ));
1839 }
1840 }
1841 opts
1842 }
1843
1844 pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1848 let equipped = self.hand_equipped_instance_ids();
1849 self.person_rows()
1850 .into_iter()
1851 .filter(|r| r.depth == 0)
1852 .filter_map(|r| {
1853 let id = r.stack.item_instance_id?;
1854 if equipped.contains(&id) {
1855 return None;
1856 }
1857 Some(StoragePickOption {
1858 item_instance_id: id,
1859 template_id: r.stack.template_id.clone(),
1860 label: storage_stack_label(&r.stack),
1861 quantity: r.stack.quantity,
1862 category: r.stack.category.clone().unwrap_or_default(),
1863 })
1864 })
1865 .collect()
1866 }
1867
1868 pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
1870 let mut ids = std::collections::HashSet::new();
1871 if let Some(id) = self.mainhand_instance_id {
1872 ids.insert(id);
1873 } else if let Some(tid) = &self.mainhand_template_id {
1874 if let Some(id) = self
1875 .inventory_stacks
1876 .iter()
1877 .find(|s| &s.template_id == tid)
1878 .and_then(|s| s.item_instance_id)
1879 {
1880 ids.insert(id);
1881 }
1882 }
1883 if let Some(id) = self.offhand_instance_id {
1884 ids.insert(id);
1885 } else if let Some(tid) = &self.offhand_template_id {
1886 if let Some(id) = self
1887 .inventory_stacks
1888 .iter()
1889 .find(|s| {
1890 &s.template_id == tid
1891 && s.item_instance_id.is_some_and(|iid| !ids.contains(&iid))
1892 })
1893 .and_then(|s| s.item_instance_id)
1894 {
1895 ids.insert(id);
1896 }
1897 }
1898 ids
1899 }
1900
1901 pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1903 let Some(panel) = &self.storage_panel else {
1904 return Vec::new();
1905 };
1906 panel
1907 .contents
1908 .iter()
1909 .filter_map(|s| {
1910 let id = s.item_instance_id?;
1911 Some(StoragePickOption {
1912 item_instance_id: id,
1913 template_id: s.template_id.clone(),
1914 label: storage_stack_label(s),
1915 quantity: s.quantity,
1916 category: s.category.clone().unwrap_or_default(),
1917 })
1918 })
1919 .collect()
1920 }
1921
1922 pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
1924 let mut opts = Vec::new();
1925 if !self
1926 .market_list_item_options(&MarketListSourceKind::Person)
1927 .is_empty()
1928 {
1929 opts.push((MarketListSourceKind::Person, "On person".into()));
1930 }
1931 if let Some(panel) = &self.market_panel {
1932 for vault in &panel.list_vaults {
1933 let source = MarketListSourceKind::TownStorage {
1934 building_id: vault.building_id.clone(),
1935 };
1936 if self.market_list_item_options(&source).is_empty() {
1937 continue;
1938 }
1939 let label = if vault.building_label.is_empty() {
1940 format!("Town storage ({})", vault.building_id)
1941 } else {
1942 format!("Town storage — {}", vault.building_label)
1943 };
1944 opts.push((source, label));
1945 }
1946 }
1947 opts
1948 }
1949
1950 pub fn market_list_item_options(
1952 &self,
1953 source: &MarketListSourceKind,
1954 ) -> Vec<StoragePickOption> {
1955 let filter = self.market_filter.as_str();
1956 let cat_filter = self.market_category_filter;
1957 let mut opts: Vec<StoragePickOption> = match source {
1958 MarketListSourceKind::Person => {
1959 let equipped = self.hand_equipped_instance_ids();
1960 self.person_rows()
1961 .into_iter()
1962 .filter(|r| r.depth == 0)
1963 .filter(|r| self.stack_is_market_listable(&r.stack))
1964 .filter_map(|r| {
1965 let id = r.stack.item_instance_id?;
1966 if equipped.contains(&id) {
1967 return None;
1968 }
1969 Some(StoragePickOption {
1970 item_instance_id: id,
1971 template_id: r.stack.template_id.clone(),
1972 label: storage_stack_label(&r.stack),
1973 quantity: r.stack.quantity,
1974 category: r
1975 .stack
1976 .category
1977 .clone()
1978 .or_else(|| {
1979 self.inventory_item_category(&r.stack.template_id)
1980 .map(str::to_string)
1981 })
1982 .unwrap_or_default(),
1983 })
1984 })
1985 .collect()
1986 }
1987 MarketListSourceKind::TownStorage { building_id } => {
1988 let Some(panel) = &self.market_panel else {
1989 return Vec::new();
1990 };
1991 let Some(vault) = panel
1992 .list_vaults
1993 .iter()
1994 .find(|v| &v.building_id == building_id)
1995 else {
1996 return Vec::new();
1997 };
1998 vault
1999 .contents
2000 .iter()
2001 .filter(|s| self.stack_is_market_listable(s))
2002 .filter_map(|s| {
2003 let id = s.item_instance_id?;
2004 Some(StoragePickOption {
2005 item_instance_id: id,
2006 template_id: s.template_id.clone(),
2007 label: storage_stack_label(s),
2008 quantity: s.quantity,
2009 category: s
2010 .category
2011 .clone()
2012 .or_else(|| {
2013 self.inventory_item_category(&s.template_id)
2014 .map(str::to_string)
2015 })
2016 .unwrap_or_default(),
2017 })
2018 })
2019 .collect()
2020 }
2021 };
2022 opts.retain(|o| {
2023 if !list_label_matches(&o.label, filter) {
2024 return false;
2025 }
2026 if let Some(group) = cat_filter {
2027 inventory_category_group(&o.category).0 == group
2028 } else {
2029 true
2030 }
2031 });
2032 opts
2033 }
2034
2035 pub fn item_base_value_copper_hint(&self, template_id: &str) -> Option<u32> {
2037 if let Some(hint) = self.inventory_hints.get(template_id) {
2038 if let Some(v) = hint.base_value_copper.filter(|v| *v > 0) {
2039 return Some(v);
2040 }
2041 }
2042 if let Some(v) = self
2043 .inventory_stacks
2044 .iter()
2045 .find(|s| s.template_id == template_id)
2046 .and_then(|s| s.base_value_copper.filter(|v| *v > 0))
2047 {
2048 return Some(v);
2049 }
2050 self.market_panel.as_ref().and_then(|panel| {
2051 panel.list_vaults.iter().find_map(|vault| {
2052 vault.contents.iter().find_map(|stack| {
2053 (stack.template_id == template_id)
2054 .then(|| stack.base_value_copper.filter(|v| *v > 0))
2055 .flatten()
2056 })
2057 })
2058 })
2059 }
2060
2061 pub fn npc_market_dump_unit_estimate(&self, template_id: &str) -> Option<u32> {
2063 let base = self.item_base_value_copper_hint(template_id)?;
2064 npc_market_dump_unit_estimate_copper(base)
2065 }
2066
2067 fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
2068 if crate::currency::is_currency(&stack.template_id) {
2069 return false;
2070 }
2071 if let Some(flag) = stack.listable {
2072 return flag;
2073 }
2074 if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
2075 return hint.listable;
2076 }
2077 let cat = stack
2078 .category
2079 .as_deref()
2080 .or_else(|| self.inventory_item_category(&stack.template_id))
2081 .unwrap_or("");
2082 category_default_listable(cat)
2083 }
2084
2085 pub fn market_available_category_groups(&self) -> Vec<&'static str> {
2087 let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
2088 match &self.market_ui_mode {
2089 MarketUiMode::ListPick { source, .. } => {
2090 let raw: Vec<_> = match source {
2091 MarketListSourceKind::Person => self
2092 .person_rows()
2093 .into_iter()
2094 .filter(|r| r.depth == 0)
2095 .filter(|r| self.stack_is_market_listable(&r.stack))
2096 .filter(|r| {
2097 list_label_matches(&storage_stack_label(&r.stack), &self.market_filter)
2098 })
2099 .map(|r| {
2100 r.stack
2101 .category
2102 .clone()
2103 .or_else(|| {
2104 self.inventory_item_category(&r.stack.template_id)
2105 .map(str::to_string)
2106 })
2107 .unwrap_or_default()
2108 })
2109 .collect(),
2110 MarketListSourceKind::TownStorage { building_id } => self
2111 .market_panel
2112 .as_ref()
2113 .and_then(|p| p.list_vaults.iter().find(|v| &v.building_id == building_id))
2114 .map(|vault| {
2115 vault
2116 .contents
2117 .iter()
2118 .filter(|s| self.stack_is_market_listable(s))
2119 .filter(|s| {
2120 list_label_matches(&storage_stack_label(s), &self.market_filter)
2121 })
2122 .map(|s| {
2123 s.category
2124 .clone()
2125 .or_else(|| {
2126 self.inventory_item_category(&s.template_id)
2127 .map(str::to_string)
2128 })
2129 .unwrap_or_default()
2130 })
2131 .collect::<Vec<_>>()
2132 })
2133 .unwrap_or_default(),
2134 };
2135 for category in raw {
2136 let (label, ord) = inventory_category_group(&category);
2137 seen.insert(ord, label);
2138 }
2139 }
2140 _ => {
2141 if let Some(panel) = &self.market_panel {
2142 for listing in &panel.listings {
2143 if !list_label_matches(&listing.display_name, &self.market_filter)
2144 && !list_label_matches(&listing.seller_label, &self.market_filter)
2145 {
2146 continue;
2147 }
2148 let (label, ord) = inventory_category_group(&listing.category);
2149 seen.insert(ord, label);
2150 }
2151 }
2152 }
2153 }
2154 seen.into_values().collect()
2155 }
2156
2157 pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
2159 let Some(panel) = &self.market_panel else {
2160 return Vec::new();
2161 };
2162 let filter = self.market_filter.as_str();
2163 let cat_filter = self.market_category_filter;
2164 panel
2165 .listings
2166 .iter()
2167 .enumerate()
2168 .filter(|(_, listing)| {
2169 if !list_label_matches(&listing.display_name, filter)
2170 && !list_label_matches(&listing.seller_label, filter)
2171 && !list_label_matches(&listing.template_id, filter)
2172 {
2173 return false;
2174 }
2175 if let Some(group) = cat_filter {
2176 inventory_category_group(&listing.category).0 == group
2177 } else {
2178 true
2179 }
2180 })
2181 .map(|(i, _)| i)
2182 .collect()
2183 }
2184
2185 pub fn clear_harvest_state(&mut self) {
2186 self.harvest_in_progress = false;
2187 self.harvest_started_at = None;
2188 }
2189
2190 fn harvest_state_stale(&self) -> bool {
2191 match self.harvest_started_at {
2192 Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
2193 None => self.harvest_in_progress,
2194 }
2195 }
2196
2197 pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
2198 self.player.as_ref().and_then(|p| p.vitals)
2199 }
2200
2201 pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
2202 let materials_ok = blueprint.inputs.iter().all(|input| {
2203 self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
2204 });
2205 let tools_ok = blueprint
2206 .required_tools
2207 .iter()
2208 .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
2209 let station_ok = match blueprint.station.as_deref() {
2210 None | Some("hand") => true,
2211 Some(tag) => self.player_at_station_tag(tag),
2212 };
2213 materials_ok && tools_ok && station_ok
2214 }
2215
2216 pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
2217 if !self.can_craft_blueprint(blueprint) {
2218 return 0;
2219 }
2220 let mut limit = u32::MAX;
2221 for input in &blueprint.inputs {
2222 if input.quantity == 0 {
2223 continue;
2224 }
2225 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2226 limit = limit.min(have / input.quantity);
2227 }
2228 for tool in &blueprint.required_tools {
2229 if tool.consumed {
2230 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2231 limit = limit.min(have);
2232 }
2233 }
2234 let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
2235 if CRAFT_STAMINA_COST > 0.0 {
2236 limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
2237 }
2238 limit
2239 }
2240
2241 pub fn clamp_craft_batch_quantity(&mut self) {
2242 let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
2243 self.craft_batch_quantity = 1;
2244 return;
2245 };
2246 let max = self.max_craft_batches(bp).max(1);
2247 self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
2248 }
2249
2250 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
2251 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
2252 return;
2253 };
2254 let max = self.max_craft_batches(&bp).max(1);
2255 let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
2256 self.craft_batch_quantity = next as u32;
2257 }
2258
2259 pub fn craft_batch_set_max(&mut self) {
2260 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
2261 return;
2262 };
2263 let max = self.max_craft_batches(&bp);
2264 self.craft_batch_quantity = if max == 0 { 1 } else { max };
2265 }
2266
2267 pub fn craft_batch_set_min(&mut self) {
2268 self.craft_batch_quantity = 1;
2269 }
2270
2271 pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
2272 let preserve_ui = self.show_shop_menu;
2273 let tab = self.shop_tab;
2274 let index = self.shop_menu_index;
2275 let qty = self.shop_quantity;
2276
2277 self.show_shop_menu = true;
2278 self.bank_panel = None;
2279 self.show_craft_menu = false;
2280 self.show_inventory_menu = false;
2281 self.show_stats = false;
2282 if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
2283 self.npc_verb_target = Some(catalog.npc_id.clone());
2284 }
2285 self.shop_catalog = Some(catalog);
2286
2287 if preserve_ui {
2288 self.shop_tab = tab;
2289 self.shop_menu_index = index;
2290 self.shop_quantity = qty;
2291 } else {
2292 self.shop_tab = ShopTab::Buy;
2293 self.shop_menu_index = 0;
2294 self.shop_quantity = 1;
2295 self.clear_shop_trade_log();
2296 }
2297 self.show_npc_verb_menu = false;
2298 self.clamp_shop_selection();
2299 }
2300
2301 pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
2302 let same_teller = self
2303 .bank_panel
2304 .as_ref()
2305 .is_some_and(|p| p.npc_id == panel.npc_id);
2306 self.bank_panel = Some(panel);
2307 self.storage_panel = None;
2308 self.market_panel = None;
2309 self.shop_catalog = None;
2310 self.show_shop_menu = false;
2311 self.show_craft_menu = false;
2312 self.show_inventory_menu = false;
2313 self.show_stats = false;
2314 self.show_npc_verb_menu = false;
2315 self.show_npc_chat = false;
2316 self.npc_chat = None;
2317 if !same_teller {
2318 self.bank_menu_index = 0;
2319 self.bank_ui_mode = BankUiMode::Menu;
2320 }
2321 if let Some(panel) = &self.bank_panel {
2322 if self.npc_verb_target.is_none() {
2323 self.npc_verb_target = Some(panel.npc_id.clone());
2324 }
2325 }
2326 }
2327
2328 pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
2329 let same_manager = self
2330 .storage_panel
2331 .as_ref()
2332 .is_some_and(|p| p.npc_id == panel.npc_id);
2333 self.storage_panel = Some(panel);
2334 self.bank_panel = None;
2335 self.market_panel = None;
2336 self.bank_ui_mode = BankUiMode::Menu;
2337 self.shop_catalog = None;
2338 self.show_shop_menu = false;
2339 self.show_craft_menu = false;
2340 self.show_inventory_menu = false;
2341 self.show_stats = false;
2342 self.show_npc_verb_menu = false;
2343 self.show_npc_chat = false;
2344 self.npc_chat = None;
2345 if !same_manager {
2346 self.storage_menu_index = 0;
2347 self.storage_ui_mode = StorageUiMode::Menu;
2348 } else {
2349 self.clamp_storage_pick_index();
2350 }
2351 if let Some(panel) = &self.storage_panel {
2352 if self.npc_verb_target.is_none() {
2353 self.npc_verb_target = Some(panel.npc_id.clone());
2354 }
2355 }
2356 }
2357
2358 pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
2359 for vault in &panel.list_vaults {
2360 self.merge_stack_catalog_hints(&vault.contents);
2361 }
2362 self.market_panel = Some(panel);
2363 self.bank_panel = None;
2364 self.storage_panel = None;
2365 self.shop_catalog = None;
2366 self.show_shop_menu = false;
2367 self.show_craft_menu = false;
2368 self.show_inventory_menu = false;
2369 self.show_stats = false;
2370 self.show_npc_verb_menu = false;
2371 self.show_npc_chat = false;
2372 self.npc_chat = None;
2373 self.market_menu_index = 0;
2374 self.market_buy_confirm = None;
2375 self.market_ui_mode = MarketUiMode::Browse;
2376 self.market_filter.clear();
2377 self.market_filter_focused = false;
2378 self.market_category_filter = None;
2379 if let Some(panel) = &self.market_panel {
2380 if self.npc_verb_target.is_none() {
2381 self.npc_verb_target = Some(panel.npc_id.clone());
2382 }
2383 }
2384 }
2385
2386 pub fn clear_market_panel(&mut self) {
2387 self.market_panel = None;
2388 self.market_menu_index = 0;
2389 self.market_buy_confirm = None;
2390 self.market_ui_mode = MarketUiMode::Browse;
2391 self.market_filter.clear();
2392 self.market_filter_focused = false;
2393 self.market_category_filter = None;
2394 }
2395
2396 pub fn clear_bank_panel(&mut self) {
2397 self.bank_panel = None;
2398 self.bank_menu_index = 0;
2399 self.bank_ui_mode = BankUiMode::Menu;
2400 }
2401
2402 pub fn clear_storage_panel(&mut self) {
2403 self.storage_panel = None;
2404 self.storage_menu_index = 0;
2405 self.storage_ui_mode = StorageUiMode::Menu;
2406 }
2407
2408 fn clamp_storage_pick_index(&mut self) {
2409 match &self.storage_ui_mode {
2410 StorageUiMode::StorePick { index } => {
2411 let n = self.storage_store_options().len();
2412 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2413 self.storage_ui_mode = StorageUiMode::StorePick { index: next };
2414 }
2415 StorageUiMode::TakePick { index } => {
2416 let n = self.storage_vault_options().len();
2417 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2418 self.storage_ui_mode = StorageUiMode::TakePick { index: next };
2419 }
2420 StorageUiMode::ShipPick {
2421 dest_building_id,
2422 dest_label,
2423 index,
2424 } => {
2425 let n = self.storage_vault_options().len();
2426 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2427 self.storage_ui_mode = StorageUiMode::ShipPick {
2428 dest_building_id: dest_building_id.clone(),
2429 dest_label: dest_label.clone(),
2430 index: next,
2431 };
2432 }
2433 StorageUiMode::Menu
2434 | StorageUiMode::StoreAmount { .. }
2435 | StorageUiMode::TakeAmount { .. }
2436 | StorageUiMode::ShipAmount { .. } => {}
2437 }
2438 }
2439
2440 pub fn shop_list_len(&self) -> usize {
2441 let Some(catalog) = &self.shop_catalog else {
2442 return 0;
2443 };
2444 match self.shop_tab {
2445 ShopTab::Buy => catalog.sells.len(),
2446 ShopTab::Sell => catalog.buys.len(),
2447 }
2448 }
2449
2450 pub fn shop_menu_move(&mut self, delta: i32) {
2451 let n = self.shop_list_len();
2452 if n == 0 {
2453 return;
2454 }
2455 let idx = self.shop_menu_index as i32;
2456 let next = (idx + delta).rem_euclid(n as i32);
2457 self.shop_menu_index = next as usize;
2458 self.clamp_shop_quantity();
2459 }
2460
2461 pub fn shop_quantity_adjust(&mut self, delta: i32) {
2462 let max = self.shop_quantity_max();
2463 if max == 0 {
2464 self.shop_quantity = 0;
2465 return;
2466 }
2467 let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
2468 self.shop_quantity = next as u32;
2469 }
2470
2471 pub(crate) fn clamp_shop_selection(&mut self) {
2472 let n = self.shop_list_len();
2473 if n == 0 {
2474 self.shop_menu_index = 0;
2475 } else {
2476 self.shop_menu_index = self.shop_menu_index.min(n - 1);
2477 }
2478 self.clamp_shop_quantity();
2479 }
2480
2481 fn shop_quantity_max(&self) -> u32 {
2482 let Some(catalog) = &self.shop_catalog else {
2483 return 1;
2484 };
2485 match self.shop_tab {
2486 ShopTab::Buy => {
2487 if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
2488 if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
2489 return 1;
2490 }
2491 }
2492 99
2493 }
2494 ShopTab::Sell => catalog
2495 .buys
2496 .get(self.shop_menu_index)
2497 .map(|l| l.quantity)
2498 .unwrap_or(0),
2499 }
2500 }
2501
2502 pub fn shop_quantity_set_max(&mut self) {
2503 self.shop_quantity = self.shop_quantity_max();
2504 }
2505
2506 pub fn shop_quantity_set_min(&mut self) {
2507 let max = self.shop_quantity_max();
2508 self.shop_quantity = if max == 0 { 0 } else { 1 };
2509 }
2510
2511 fn clamp_shop_quantity(&mut self) {
2512 let max = self.shop_quantity_max();
2513 if max == 0 {
2514 self.shop_quantity = 0;
2515 } else {
2516 self.shop_quantity = self.shop_quantity.max(1).min(max);
2517 }
2518 }
2519
2520 pub fn player_at_station_tag(&self, tag: &str) -> bool {
2521 let Some(id) = self.effective_inside_building() else {
2522 return false;
2523 };
2524 self.buildings
2525 .iter()
2526 .find(|b| b.id == id)
2527 .is_some_and(|b| b.tags.iter().any(|t| t == tag))
2528 }
2529
2530 pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
2532 if self.can_craft_blueprint(blueprint) {
2533 return None;
2534 }
2535 let mut missing = Vec::new();
2536 for input in &blueprint.inputs {
2537 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2538 if have < input.quantity {
2539 let name = self.blueprint_ingredient_label(input);
2540 missing.push(format!("{}×{} (have {have})", input.quantity, name));
2541 }
2542 }
2543 for tool in &blueprint.required_tools {
2544 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2545 if have < 1 {
2546 missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
2547 }
2548 }
2549 if let Some(station) = blueprint.station.as_deref() {
2550 if station != "hand" && !self.player_at_station_tag(station) {
2551 missing.push(format!("station: {station} (enter building)"));
2552 }
2553 }
2554 if missing.is_empty() {
2555 None
2556 } else {
2557 Some(missing.join(", "))
2558 }
2559 }
2560
2561 pub fn player_entity(&self) -> Option<&EntityState> {
2562 self.player
2563 .as_ref()
2564 .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
2565 }
2566
2567 pub fn apply_client_ui_prefs(&mut self) {
2569 let cfg = crate::client_config::ClientConfig::load();
2570 if let Some(hidden) = cfg.hud_log_hidden {
2571 self.hud_log_hidden = hidden;
2572 }
2573 if let Some(compact) = cfg.workers_menu_compact {
2574 self.workers_menu_compact = compact;
2575 }
2576 }
2577
2578 pub fn player_position(&self) -> (f32, f32) {
2579 let (x, y, _) = self.player_position_with_z();
2580 (x, y)
2581 }
2582
2583 pub fn player_position_with_z(&self) -> (f32, f32, f32) {
2584 if let Some(p) = self.player_entity() {
2585 (
2586 p.transform.position.x,
2587 p.transform.position.y,
2588 p.transform.position.z,
2589 )
2590 } else {
2591 (0.0, 0.0, 0.0)
2592 }
2593 }
2594
2595 pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
2596 let mut rows: Vec<(String, u32, String)> = self
2597 .inventory
2598 .iter()
2599 .filter(|(_, q)| **q > 0)
2600 .map(|(id, qty)| {
2601 let label = self
2602 .inventory_hints
2603 .get(id)
2604 .map(|h| h.display_name.clone())
2605 .unwrap_or_else(|| id.clone());
2606 (id.clone(), *qty, label)
2607 })
2608 .collect();
2609 rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
2610 rows
2611 }
2612
2613 pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
2614 self.inventory_hints
2615 .get(template_id)
2616 .map(|h| h.category.as_str())
2617 .filter(|c| !c.is_empty())
2618 }
2619
2620 pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
2621 stack
2622 .props
2623 .get("grants_item_status_effect")
2624 .map(|s| !s.is_empty())
2625 .unwrap_or(false)
2626 }
2627
2628 pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
2629 stack
2630 .props
2631 .get("grants_item_status_effect")
2632 .map(String::as_str)
2633 .filter(|s| !s.is_empty())
2634 }
2635
2636 pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
2637 stack
2638 .props
2639 .get("grants_item_status_mode")
2640 .map(String::as_str)
2641 .unwrap_or("on_hit")
2642 }
2643
2644 pub fn grant_target_options(
2646 &self,
2647 grant: &flatland_protocol::ItemStack,
2648 ) -> Vec<GrantTargetOption> {
2649 let mode = Self::grant_mode(grant);
2650 let grant_tags: Vec<&str> = grant
2651 .props
2652 .get("grants_item_status_tags")
2653 .map(|s| {
2654 s.split(',')
2655 .map(str::trim)
2656 .filter(|t| !t.is_empty())
2657 .collect()
2658 })
2659 .unwrap_or_default();
2660 let grant_id = grant.item_instance_id;
2661 let mut out = Vec::new();
2662 let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
2663 let Some(iid) = stack.item_instance_id else {
2664 return;
2665 };
2666 if Some(iid) == grant_id {
2667 return;
2668 }
2669 if stack.props.get("enchantable").map(String::as_str) == Some("0") {
2670 return;
2671 }
2672 if !grant_target_matches_mode(stack, mode) {
2673 return;
2674 }
2675 if !grant_tags_match(stack, &grant_tags) {
2676 return;
2677 }
2678 let name = stack
2679 .display_name
2680 .clone()
2681 .unwrap_or_else(|| stack.template_id.clone());
2682 let bindings = if stack.status_bindings.is_empty() {
2683 String::new()
2684 } else {
2685 format!(
2686 " · {}",
2687 stack
2688 .status_bindings
2689 .iter()
2690 .map(|b| b.effect_id.as_str())
2691 .collect::<Vec<_>>()
2692 .join(", ")
2693 )
2694 };
2695 out.push(GrantTargetOption {
2696 label: format!("{where_label}: {name}{bindings}"),
2697 target_instance_id: iid,
2698 });
2699 };
2700 fn walk(
2701 stacks: &[flatland_protocol::ItemStack],
2702 where_label: &str,
2703 push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
2704 ) {
2705 for s in stacks {
2706 push(s, where_label);
2707 if !s.contents.is_empty() {
2708 let nested = format!(
2709 "{where_label}/{}",
2710 s.display_name.as_deref().unwrap_or(s.template_id.as_str())
2711 );
2712 walk(&s.contents, &nested, push);
2713 }
2714 }
2715 }
2716 walk(&self.inventory_stacks, "Bag", &mut push);
2717 for (slot, stack) in &self.worn {
2718 push(stack, body_slot_label(*slot));
2719 let nest = format!(
2720 "{}/{}",
2721 body_slot_label(*slot),
2722 stack
2723 .display_name
2724 .as_deref()
2725 .unwrap_or(stack.template_id.as_str())
2726 );
2727 walk(&stack.contents, &nest, &mut push);
2728 }
2729 out
2730 }
2731
2732 pub fn item_base_mass(&self, template_id: &str) -> f32 {
2733 self.inventory_hints
2734 .get(template_id)
2735 .and_then(|h| h.base_mass)
2736 .unwrap_or(0.5)
2737 }
2738
2739 pub fn item_base_volume(&self, template_id: &str) -> f32 {
2740 self.inventory_hints
2741 .get(template_id)
2742 .and_then(|h| h.base_volume)
2743 .unwrap_or(1.0)
2744 }
2745
2746 pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
2747 let unit = stack
2748 .base_mass
2749 .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
2750 unit * stack.quantity as f32
2751 }
2752
2753 fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
2754 let unit = stack.base_volume.unwrap_or(1.0);
2755 unit * stack.quantity as f32
2756 + stack
2757 .contents
2758 .iter()
2759 .map(Self::stack_tree_volume)
2760 .sum::<f32>()
2761 }
2762
2763 fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
2764 contents.iter().map(Self::stack_tree_volume).sum()
2765 }
2766
2767 fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
2768 self.inventory_hints
2769 .get(template_id)
2770 .and_then(|h| h.capacity_volume)
2771 .filter(|c| *c > 0.0)
2772 }
2773
2774 fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
2775 stack
2776 .capacity_volume
2777 .filter(|c| *c > 0.0)
2778 .or_else(|| self.template_capacity_volume(&stack.template_id))
2779 }
2780
2781 pub fn container_volume_label(&self, row: &InventoryRow) -> String {
2783 let Some((used, cap)) = self.container_volume_stats(row) else {
2784 return String::new();
2785 };
2786 let free = (cap - used).max(0.0);
2787 format!(" vol {used:.0}/{cap:.0} ({free:.0} free)")
2788 }
2789
2790 fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
2791 if row.is_chest_shell {
2792 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
2793 return None;
2794 };
2795 let chest = self
2796 .placed_containers
2797 .iter()
2798 .find(|c| c.id == *container_id)?;
2799 let cap = self
2800 .stack_capacity_volume(&row.stack)
2801 .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
2802 let used = if chest.accessible {
2803 Self::contents_used_volume(&chest.contents)
2804 } else {
2805 0.0
2806 };
2807 return Some((used, cap));
2808 }
2809
2810 let cap = self.stack_capacity_volume(&row.stack)?;
2811 let used = Self::contents_used_volume(&row.stack.contents);
2812 Some((used, cap))
2813 }
2814
2815 pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
2816 if row.is_chest_shell {
2817 return true;
2818 }
2819 if row.is_equip_shell {
2820 return self.inventory_item_category(&row.stack.template_id) == Some("container");
2821 }
2822 self.inventory_item_category(&row.stack.template_id) == Some("container")
2823 || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
2824 }
2825
2826 fn container_stack_for(
2827 &self,
2828 location: &flatland_protocol::InventoryLocation,
2829 parent_instance_id: Option<uuid::Uuid>,
2830 ) -> Option<flatland_protocol::ItemStack> {
2831 match location {
2832 flatland_protocol::InventoryLocation::Root => {
2833 let pid = parent_instance_id?;
2834 self.find_stack_by_instance(&self.inventory_stacks, pid)
2835 }
2836 flatland_protocol::InventoryLocation::Worn { slot } => {
2837 let worn = self.worn.get(slot)?;
2838 if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
2839 Some(worn.clone())
2840 } else {
2841 self.find_stack_by_instance(&worn.contents, parent_instance_id?)
2842 }
2843 }
2844 flatland_protocol::InventoryLocation::Placed { container_id } => {
2845 let chest = self
2846 .placed_containers
2847 .iter()
2848 .find(|c| c.id == *container_id)?;
2849 if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
2850 Some(flatland_protocol::ItemStack {
2851 template_id: chest.template_id.clone(),
2852 quantity: 1,
2853 item_instance_id: chest.item_instance_id,
2854 props: Default::default(),
2855 status_bindings: Vec::new(),
2856 contents: chest.contents.clone(),
2857 display_name: Some(chest.display_name.clone()),
2858 category: Some("container".into()),
2859 capacity_volume: self
2860 .inventory_hints
2861 .get(&chest.template_id)
2862 .and_then(|h| h.capacity_volume),
2863 worker_lodging_capacity: chest.worker_lodging_capacity,
2864 ..Default::default()
2865 })
2866 } else {
2867 self.find_stack_by_instance(&chest.contents, parent_instance_id?)
2868 }
2869 }
2870 flatland_protocol::InventoryLocation::Keychain => None,
2871 flatland_protocol::InventoryLocation::WhisperPouch => None,
2872 }
2873 }
2874
2875 fn find_stack_by_instance(
2876 &self,
2877 stacks: &[flatland_protocol::ItemStack],
2878 instance_id: uuid::Uuid,
2879 ) -> Option<flatland_protocol::ItemStack> {
2880 for stack in stacks {
2881 if stack.item_instance_id == Some(instance_id) {
2882 return Some(stack.clone());
2883 }
2884 if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
2885 return Some(found);
2886 }
2887 }
2888 None
2889 }
2890
2891 pub fn max_movable_to(
2893 &self,
2894 template_id: &str,
2895 stack_qty: u32,
2896 from: &flatland_protocol::InventoryLocation,
2897 to: &flatland_protocol::InventoryLocation,
2898 parent_instance_id: Option<uuid::Uuid>,
2899 ) -> u32 {
2900 let unit_vol = self.item_base_volume(template_id);
2901 let unit_mass = self.item_base_mass(template_id);
2902 let mut limit = stack_qty;
2903
2904 if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2905 let cap = parent
2906 .capacity_volume
2907 .or_else(|| {
2908 self.inventory_hints
2909 .get(&parent.template_id)
2910 .and_then(|h| h.capacity_volume)
2911 })
2912 .unwrap_or(0.0);
2913 if cap > 0.0 && unit_vol > 0.0 {
2914 let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2915 limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2916 }
2917 }
2918
2919 let to_person = matches!(
2920 to,
2921 flatland_protocol::InventoryLocation::Root
2922 | flatland_protocol::InventoryLocation::Worn { .. }
2923 );
2924 let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2925 if to_person && from_placed && unit_mass > 0.0 {
2926 let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2927 if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2928 limit = 0;
2929 } else {
2930 limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2931 }
2932 }
2933
2934 limit.max(0).min(stack_qty)
2935 }
2936
2937 pub fn move_picker_max_at_selection(&self) -> u32 {
2938 let Some(picker) = &self.move_picker else {
2939 return 1;
2940 };
2941 let Some(opt) = picker.options.get(self.move_picker_index) else {
2942 return picker.stack_quantity;
2943 };
2944 match &opt.kind {
2945 MoveOptionKind::Cancel
2946 | MoveOptionKind::Drop
2947 | MoveOptionKind::Use
2948 | MoveOptionKind::GrantApply
2949 | MoveOptionKind::SellPlotToCrown { .. }
2950 | MoveOptionKind::PickupPlaced { .. }
2951 | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
2952 MoveOptionKind::Move {
2953 location,
2954 parent_instance_id,
2955 } => self.max_movable_to(
2956 &picker.template_id,
2957 picker.stack_quantity,
2958 &picker.from,
2959 location,
2960 *parent_instance_id,
2961 ),
2962 }
2963 }
2964
2965 pub fn clamp_move_picker_quantity(&mut self) {
2966 let max = self.move_picker_max_at_selection();
2967 if let Some(picker) = &mut self.move_picker {
2968 if max == 0 {
2969 picker.quantity = 1;
2970 } else {
2971 picker.quantity = picker.quantity.clamp(1, max);
2972 }
2973 }
2974 }
2975
2976 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2977 let max = self.move_picker_max_at_selection().max(1);
2978 if let Some(picker) = &mut self.move_picker {
2979 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2980 picker.quantity = next as u32;
2981 }
2982 }
2983
2984 pub fn move_picker_set_quantity_max(&mut self) {
2985 let max = self.move_picker_max_at_selection();
2986 if let Some(picker) = &mut self.move_picker {
2987 picker.quantity = if max == 0 {
2988 1
2989 } else {
2990 max.min(picker.stack_quantity)
2991 };
2992 }
2993 }
2994
2995 pub fn move_picker_set_quantity_min(&mut self) {
2996 if let Some(picker) = &mut self.move_picker {
2997 picker.quantity = 1;
2998 }
2999 }
3000
3001 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
3002 if let Some(picker) = &mut self.destroy_picker {
3003 let max = picker.stack_quantity.max(1);
3004 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3005 picker.quantity = next as u32;
3006 }
3007 }
3008
3009 pub fn destroy_picker_set_quantity_max(&mut self) {
3010 if let Some(picker) = &mut self.destroy_picker {
3011 picker.quantity = picker.stack_quantity.max(1);
3012 }
3013 }
3014
3015 pub fn destroy_picker_set_quantity_min(&mut self) {
3016 if let Some(picker) = &mut self.destroy_picker {
3017 picker.quantity = 1;
3018 }
3019 }
3020
3021 pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3022 let have = self.inventory.get(template_id).copied().unwrap_or(0);
3023 (have, have >= need)
3024 }
3025
3026 pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3028 let have = self
3029 .plot_build_offer
3030 .as_ref()
3031 .and_then(|o| {
3032 o.available
3033 .iter()
3034 .find(|s| s.template_id == template_id)
3035 .map(|s| s.quantity)
3036 })
3037 .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
3038 (have, have >= need)
3039 }
3040
3041 pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3042 self.building_materials
3043 .iter()
3044 .filter(|m| m.can_wall)
3045 .collect()
3046 }
3047
3048 pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3049 self.building_materials
3050 .iter()
3051 .filter(|m| m.can_roof)
3052 .collect()
3053 }
3054
3055 pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3056 self.plot_build_wall_options()
3057 .get(self.plot_build_wall_index)
3058 .copied()
3059 }
3060
3061 pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3062 self.plot_build_roof_options()
3063 .get(self.plot_build_roof_index)
3064 .copied()
3065 }
3066
3067 pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
3069 let Some(wall) = self.plot_build_selected_wall() else {
3070 return Vec::new();
3071 };
3072 let Some(roof) = self.plot_build_selected_roof() else {
3073 return Vec::new();
3074 };
3075 let area = self
3076 .plot_build_offer
3077 .as_ref()
3078 .filter(|o| o.pad_ok)
3079 .map(|o| o.pad_width_m * o.pad_depth_m)
3080 .unwrap_or(0.0);
3081 if area <= 0.0 {
3082 return Vec::new();
3083 }
3084 let mut map: std::collections::HashMap<String, (String, u32)> =
3085 std::collections::HashMap::new();
3086 for line in &wall.wall_bom {
3087 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3088 if qty == 0 {
3089 continue;
3090 }
3091 let name = if line.display_name.is_empty() {
3092 line.template_id.clone()
3093 } else {
3094 line.display_name.clone()
3095 };
3096 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3097 entry.1 = entry.1.saturating_add(qty);
3098 }
3099 for line in &roof.roof_bom {
3100 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3101 if qty == 0 {
3102 continue;
3103 }
3104 let name = if line.display_name.is_empty() {
3105 line.template_id.clone()
3106 } else {
3107 line.display_name.clone()
3108 };
3109 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3110 entry.1 = entry.1.saturating_add(qty);
3111 }
3112 let mut out: Vec<_> = map
3113 .into_iter()
3114 .map(|(id, (name, qty))| (id, name, qty))
3115 .collect();
3116 out.sort_by(|a, b| a.0.cmp(&b.0));
3117 out
3118 }
3119
3120 pub fn plot_build_duration_secs(&self) -> Option<f32> {
3121 let wall = self.plot_build_selected_wall()?;
3122 let roof = self.plot_build_selected_roof()?;
3123 let offer = self.plot_build_offer.as_ref()?;
3124 if !offer.pad_ok {
3125 return None;
3126 }
3127 let area = offer.pad_width_m * offer.pad_depth_m;
3128 let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
3129 let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
3130 Some(ticks.max(2.0) / 30.0)
3131 }
3132
3133 pub fn plot_build_can_afford(&self) -> bool {
3134 if self.plot_build_offer.as_ref().is_none_or(|o| !o.pad_ok) {
3135 return false;
3136 }
3137 self.plot_build_bom_lines()
3138 .iter()
3139 .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
3140 }
3141
3142 pub fn currency_display(&self) -> String {
3143 crate::currency::currency_line(&self.inventory)
3144 }
3145
3146 pub fn in_shallow_water(&self) -> bool {
3148 let (px, py) = self.player_position();
3149 self.terrain_at(px, py)
3150 .is_some_and(|k| k == TerrainKindView::ShallowWater)
3151 }
3152
3153 pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
3154 self.terrain_zone_at(x, y).map(|z| z.kind)
3155 }
3156
3157 pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
3159 use std::cell::RefCell;
3160
3161 const CHUNK: i32 = 8;
3162 thread_local! {
3163 static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
3164 RefCell::new(None);
3165 }
3166
3167 let zones = &self.terrain_zones;
3168 if zones.is_empty() {
3169 return None;
3170 }
3171 if zones.len() <= 48 {
3172 return zones
3173 .iter()
3174 .enumerate()
3175 .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
3176 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
3177 .map(|(_, z)| z);
3178 }
3179
3180 let ptr = zones.as_ptr();
3181 let len = zones.len();
3182 INDEX.with(|cell| {
3183 let mut slot = cell.borrow_mut();
3184 let stale = match slot.as_ref() {
3185 Some((p, l, _)) => *p != ptr || *l != len,
3186 None => true,
3187 };
3188 if stale {
3189 let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
3190 std::collections::HashMap::new();
3191 for (zi, z) in zones.iter().enumerate() {
3192 let x0 = z.x0.min(z.x1).floor() as i32;
3193 let y0 = z.y0.min(z.y1).floor() as i32;
3194 let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
3195 let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
3196 let cx0 = x0.div_euclid(CHUNK);
3197 let cy0 = y0.div_euclid(CHUNK);
3198 let cx1 = x1.div_euclid(CHUNK);
3199 let cy1 = y1.div_euclid(CHUNK);
3200 for cy in cy0..=cy1 {
3201 for cx in cx0..=cx1 {
3202 chunks.entry((cx, cy)).or_default().push(zi);
3203 }
3204 }
3205 }
3206 *slot = Some((ptr, len, chunks));
3207 }
3208 let chunks = &slot.as_ref().expect("index").2;
3209 let cx = (x.floor() as i32).div_euclid(CHUNK);
3210 let cy = (y.floor() as i32).div_euclid(CHUNK);
3211 let mut best: Option<(usize, &TerrainZoneView)> = None;
3212 if let Some(list) = chunks.get(&(cx, cy)) {
3213 for &zi in list {
3214 let Some(z) = zones.get(zi) else { continue };
3215 if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
3216 continue;
3217 }
3218 best = match best {
3219 None => Some((zi, z)),
3220 Some((bi, bz)) => {
3221 if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
3222 Some((zi, z))
3223 } else {
3224 Some((bi, bz))
3225 }
3226 }
3227 };
3228 }
3229 }
3230 best.map(|(_, z)| z)
3231 })
3232 }
3233
3234 pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
3236 self.terrain_zone_at(x, y)
3237 .map(|z| z.elevation)
3238 .unwrap_or(0.0)
3239 }
3240
3241 pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
3243 const TOL: f32 = 0.35;
3244 let mut levels = vec![self.elevation_at(x, y)];
3245 for p in &self.z_platforms {
3246 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
3247 levels.push(p.z);
3248 }
3249 }
3250 levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
3251 levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
3252 levels
3253 }
3254
3255 pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
3256 const TOL: f32 = 0.35;
3257 self.walkable_levels_at(x, y)
3258 .iter()
3259 .any(|&l| (l - z).abs() <= TOL)
3260 }
3261
3262 pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
3263 let mut top = self.elevation_at(x, y);
3264 for p in &self.z_platforms {
3265 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
3266 top = top.max(p.z);
3267 }
3268 }
3269 top
3270 }
3271
3272 pub fn effective_inside_building(&self) -> Option<String> {
3274 self.player_entity().and_then(|p| p.inside_building.clone())
3275 }
3276
3277 pub fn placed_container_in_current_space(
3281 &self,
3282 c: &flatland_protocol::PlacedContainerView,
3283 ) -> bool {
3284 match (
3285 self.effective_inside_building().as_deref(),
3286 c.building_id.as_deref(),
3287 ) {
3288 (None, None) => true,
3289 (Some(a), Some(b)) => a == b,
3290 _ => false,
3291 }
3292 }
3293
3294 fn merge_stack_catalog_hints(&mut self, stacks: &[flatland_protocol::ItemStack]) {
3295 fn walk(
3296 stacks: &[flatland_protocol::ItemStack],
3297 hints: &mut std::collections::HashMap<String, InventoryHint>,
3298 ) {
3299 for stack in stacks {
3300 if stack.display_name.is_some()
3301 || stack.category.is_some()
3302 || stack.base_mass.is_some()
3303 || stack.base_volume.is_some()
3304 || stack.base_value_copper.is_some()
3305 {
3306 hints.insert(
3307 stack.template_id.clone(),
3308 InventoryHint {
3309 display_name: stack
3310 .display_name
3311 .clone()
3312 .unwrap_or_else(|| stack.template_id.clone()),
3313 category: stack.category.clone().unwrap_or_default(),
3314 base_mass: stack.base_mass,
3315 base_volume: stack.base_volume,
3316 capacity_volume: stack.capacity_volume,
3317 stackable: stack.stackable.unwrap_or(true),
3318 listable: stack.listable.unwrap_or_else(|| {
3319 category_default_listable(stack.category.as_deref().unwrap_or(""))
3320 }),
3321 base_value_copper: stack.base_value_copper,
3322 },
3323 );
3324 }
3325 walk(&stack.contents, hints);
3326 }
3327 }
3328 walk(stacks, &mut self.inventory_hints);
3329 }
3330
3331 pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
3332 self.inventory_stacks = stacks.to_vec();
3333 self.inventory.clear();
3334 self.inventory_hints.clear();
3335 fn walk(
3336 stacks: &[flatland_protocol::ItemStack],
3337 inventory: &mut std::collections::HashMap<String, u32>,
3338 hints: &mut std::collections::HashMap<String, InventoryHint>,
3339 ) {
3340 for stack in stacks {
3341 *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
3342 if stack.display_name.is_some()
3343 || stack.category.is_some()
3344 || stack.base_mass.is_some()
3345 || stack.base_volume.is_some()
3346 || stack.base_value_copper.is_some()
3347 {
3348 hints.insert(
3349 stack.template_id.clone(),
3350 InventoryHint {
3351 display_name: stack
3352 .display_name
3353 .clone()
3354 .unwrap_or_else(|| stack.template_id.clone()),
3355 category: stack.category.clone().unwrap_or_default(),
3356 base_mass: stack.base_mass,
3357 base_volume: stack.base_volume,
3358 capacity_volume: stack.capacity_volume,
3359 stackable: stack.stackable.unwrap_or(true),
3360 listable: stack.listable.unwrap_or_else(|| {
3361 category_default_listable(stack.category.as_deref().unwrap_or(""))
3362 }),
3363 base_value_copper: stack.base_value_copper,
3364 },
3365 );
3366 }
3367 walk(&stack.contents, inventory, hints);
3368 }
3369 }
3370 walk(stacks, &mut self.inventory, &mut self.inventory_hints);
3371 for item in self.worn.values() {
3373 walk(
3374 std::slice::from_ref(item),
3375 &mut self.inventory,
3376 &mut self.inventory_hints,
3377 );
3378 }
3379 }
3380
3381 fn remove_carried_instance(&mut self, instance_id: uuid::Uuid, quantity: Option<u32>) {
3385 fn take_from(
3386 stacks: &mut Vec<flatland_protocol::ItemStack>,
3387 instance_id: uuid::Uuid,
3388 qty: Option<u32>,
3389 ) -> bool {
3390 if let Some(i) = stacks
3391 .iter()
3392 .position(|s| s.item_instance_id == Some(instance_id))
3393 {
3394 let have = stacks[i].quantity;
3395 let take = qty.unwrap_or(have).min(have);
3396 if take >= have {
3397 stacks.remove(i);
3398 } else {
3399 stacks[i].quantity = have - take;
3400 }
3401 return true;
3402 }
3403 stacks
3404 .iter_mut()
3405 .any(|stack| take_from(&mut stack.contents, instance_id, qty))
3406 }
3407
3408 if take_from(&mut self.inventory_stacks, instance_id, quantity) {
3409 let stacks = self.inventory_stacks.clone();
3410 self.sync_inventory_from_stacks(&stacks);
3411 self.refresh_inventory_ui();
3412 return;
3413 }
3414 let slots: Vec<_> = self.worn.keys().copied().collect();
3415 for slot in slots {
3416 let Some(item) = self.worn.get_mut(&slot) else {
3417 continue;
3418 };
3419 if take_from(&mut item.contents, instance_id, quantity) {
3420 let stacks = self.inventory_stacks.clone();
3421 self.sync_inventory_from_stacks(&stacks);
3422 self.refresh_inventory_ui();
3423 return;
3424 }
3425 }
3426 }
3427
3428 pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
3431 if notice.message.starts_with("Gave ") {
3435 if notice.coins_delta != 0 {
3436 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
3437 let stacks = self.inventory_stacks.clone();
3438 self.sync_inventory_from_stacks(&stacks);
3439 }
3440 self.record_shop_trade_notice(notice);
3441 return;
3442 }
3443 let subtract_items =
3444 notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
3445 for stack in ¬ice.inventory_delta {
3446 if stack.quantity == 0 {
3447 continue;
3448 }
3449 if subtract_items {
3450 crate::currency::drain_template_stacks(
3451 &mut self.inventory_stacks,
3452 &stack.template_id,
3453 stack.quantity,
3454 );
3455 continue;
3456 }
3457 let stackable = self
3458 .inventory_hints
3459 .get(&stack.template_id)
3460 .map(|h| h.stackable)
3461 .or(stack.stackable)
3462 .unwrap_or(true);
3463 if stackable {
3464 if let Some(existing) = self
3465 .inventory_stacks
3466 .iter_mut()
3467 .find(|s| s.template_id == stack.template_id)
3468 {
3469 existing.quantity = existing.quantity.saturating_add(stack.quantity);
3470 if stack.display_name.is_some() {
3471 existing.display_name = stack.display_name.clone();
3472 }
3473 if stack.category.is_some() {
3474 existing.category = stack.category.clone();
3475 }
3476 continue;
3477 }
3478 }
3479 self.inventory_stacks.push(stack.clone());
3480 }
3481 if notice.coins_delta != 0 {
3482 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
3483 }
3484 if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
3485 let stacks = self.inventory_stacks.clone();
3486 self.sync_inventory_from_stacks(&stacks);
3487 }
3488 self.record_shop_trade_notice(notice);
3489 }
3490
3491 pub fn worn_rows(&self) -> Vec<InventoryRow> {
3496 let mut rows = Vec::new();
3497 for (slot, item) in &self.worn {
3498 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3499 rows.push(InventoryRow {
3500 depth: 0,
3501 stack: item.clone(),
3502 from: from.clone(),
3503 from_parent_instance_id: None,
3504 is_equip_shell: true,
3505 is_chest_shell: false,
3506 section: InventorySection::Worn,
3507 });
3508 for child in &item.contents {
3509 push_inventory_rows(
3510 &mut rows,
3511 1,
3512 child,
3513 &from,
3514 item.item_instance_id,
3515 InventorySection::Worn,
3516 );
3517 }
3518 }
3519 rows
3520 }
3521
3522 pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
3524 let equipped = self.hand_equipped_instance_ids();
3525 self.inventory_stacks
3526 .iter()
3527 .filter(|s| s.item_instance_id.is_some_and(|id| !equipped.contains(&id)))
3528 .collect()
3529 }
3530
3531 pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
3533 let equipped = self.hand_equipped_instance_ids();
3534 self.inventory_stacks
3535 .iter()
3536 .filter_map(|stack| {
3537 let item_instance_id = stack.item_instance_id?;
3538 if equipped.contains(&item_instance_id) {
3539 return None;
3540 }
3541 let label = stack
3542 .display_name
3543 .clone()
3544 .unwrap_or_else(|| stack.template_id.clone());
3545 let label = if stack.quantity > 1 {
3546 format!("{label} ×{}", stack.quantity)
3547 } else {
3548 label
3549 };
3550 Some(WorkerGiveOption {
3551 item_instance_id,
3552 label,
3553 quantity: stack.quantity,
3554 template_id: stack.template_id.clone(),
3555 })
3556 })
3557 .collect()
3558 }
3559
3560 pub fn teachable_blueprint_options(
3562 &self,
3563 worker: &flatland_protocol::HiredWorkerView,
3564 ) -> Vec<WorkerTeachOption> {
3565 let copper = crate::currency::copper_from_counts(&self.inventory);
3566 let mut options: Vec<WorkerTeachOption> = self
3567 .blueprints
3568 .iter()
3569 .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
3570 .map(|bp| {
3571 let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
3572 let cost = bp.worker_train_copper;
3573 WorkerTeachOption {
3574 blueprint_id: bp.id.clone(),
3575 label: if bp.label.is_empty() {
3576 bp.id.clone()
3577 } else {
3578 bp.label.clone()
3579 },
3580 cost_copper: cost,
3581 min_level,
3582 worker_level: worker.level,
3583 can_afford: copper >= cost,
3584 level_ok: worker.level >= min_level,
3585 }
3586 })
3587 .collect();
3588 options.sort_by(|a, b| a.label.cmp(&b.label));
3589 options
3590 }
3591
3592 pub fn person_rows(&self) -> Vec<InventoryRow> {
3595 self.person_rows_filtered("")
3596 }
3597
3598 pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3599 let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
3600 roots.sort_by(|a, b| {
3601 let ca = a
3602 .category
3603 .as_deref()
3604 .or_else(|| self.inventory_item_category(&a.template_id))
3605 .unwrap_or("");
3606 let cb = b
3607 .category
3608 .as_deref()
3609 .or_else(|| self.inventory_item_category(&b.template_id))
3610 .unwrap_or("");
3611 let ga = inventory_category_group(ca).1;
3612 let gb = inventory_category_group(cb).1;
3613 ga.cmp(&gb).then_with(|| {
3614 let na = a.display_name.as_deref().unwrap_or(a.template_id.as_str());
3615 let nb = b.display_name.as_deref().unwrap_or(b.template_id.as_str());
3616 na.cmp(nb)
3617 })
3618 });
3619 let mut rows = Vec::new();
3620 for stack in roots {
3621 push_inventory_rows_filtered(
3622 &mut rows,
3623 0,
3624 stack,
3625 &flatland_protocol::InventoryLocation::Root,
3626 None,
3627 InventorySection::Person,
3628 filter,
3629 );
3630 }
3631 rows
3632 }
3633
3634 pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3635 if filter.is_empty() {
3636 return self.worn_rows();
3637 }
3638 let mut rows = Vec::new();
3639 for (slot, item) in &self.worn {
3640 if !stack_matches_filter(item, filter) {
3641 continue;
3642 }
3643 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3644 let self_hit = {
3645 let f = filter.to_ascii_lowercase();
3646 let name = item
3647 .display_name
3648 .as_deref()
3649 .unwrap_or("")
3650 .to_ascii_lowercase();
3651 let tid = item.template_id.to_ascii_lowercase();
3652 name.contains(&f) || tid.contains(&f)
3653 };
3654 rows.push(InventoryRow {
3655 depth: 0,
3656 stack: item.clone(),
3657 from: from.clone(),
3658 from_parent_instance_id: None,
3659 is_equip_shell: true,
3660 is_chest_shell: false,
3661 section: InventorySection::Worn,
3662 });
3663 for child in &item.contents {
3664 if self_hit || stack_matches_filter(child, filter) {
3665 push_inventory_rows_filtered(
3666 &mut rows,
3667 1,
3668 child,
3669 &from,
3670 item.item_instance_id,
3671 InventorySection::Worn,
3672 if self_hit { "" } else { filter },
3673 );
3674 }
3675 }
3676 }
3677 rows
3678 }
3679
3680 pub fn carried_worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3684 let mut rows = Vec::new();
3685 for (slot, item) in &self.worn {
3686 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3687 for child in &item.contents {
3688 push_inventory_rows_filtered(
3689 &mut rows,
3690 0,
3691 child,
3692 &from,
3693 item.item_instance_id,
3694 InventorySection::Person,
3695 filter,
3696 );
3697 }
3698 }
3699 rows
3700 }
3701
3702 pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
3704 let mut rows = self.worn_rows();
3705 rows.extend(self.person_rows());
3706 rows.into_iter().map(|r| (r.depth, r.stack)).collect()
3707 }
3708
3709 pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
3713 let (px, py) = self.player_position();
3714 let mut list: Vec<NearbyContainer> = self
3715 .placed_containers
3716 .iter()
3717 .filter(|c| self.placed_container_in_current_space(c))
3718 .filter_map(|c| {
3719 let distance_m = (c.x - px).hypot(c.y - py);
3720 if distance_m > CONTAINER_RANGE_M {
3721 return None;
3722 }
3723 let mut rows = Vec::new();
3724 let from = flatland_protocol::InventoryLocation::Placed {
3725 container_id: c.id.clone(),
3726 };
3727 rows.push(InventoryRow {
3728 depth: 0,
3729 stack: flatland_protocol::ItemStack {
3730 template_id: c.template_id.clone(),
3731 quantity: 1,
3732 item_instance_id: c.item_instance_id,
3733 props: Default::default(),
3734 status_bindings: Vec::new(),
3735 contents: Vec::new(),
3736 display_name: Some(c.display_name.clone()),
3737 category: Some("container".into()),
3738 capacity_volume: c.capacity_volume,
3739 worker_lodging_capacity: c.worker_lodging_capacity,
3740 ..Default::default()
3741 },
3742 from: from.clone(),
3743 from_parent_instance_id: None,
3744 is_equip_shell: false,
3745 is_chest_shell: true,
3746 section: InventorySection::Nearby,
3747 });
3748 if c.accessible {
3749 for child in &c.contents {
3750 push_inventory_rows(
3751 &mut rows,
3752 1,
3753 child,
3754 &from,
3755 c.item_instance_id,
3756 InventorySection::Nearby,
3757 );
3758 }
3759 }
3760 Some(NearbyContainer {
3761 view: c.clone(),
3762 distance_m,
3763 rows,
3764 })
3765 })
3766 .collect();
3767 list.sort_by(|a, b| {
3768 a.distance_m
3769 .partial_cmp(&b.distance_m)
3770 .unwrap_or(std::cmp::Ordering::Equal)
3771 });
3772 list
3773 }
3774
3775 pub fn nearest_placed_container(
3777 &self,
3778 max_dist: f32,
3779 ) -> Option<flatland_protocol::PlacedContainerView> {
3780 let (px, py) = self.player_position();
3781 self.placed_containers
3782 .iter()
3783 .filter(|c| self.placed_container_in_current_space(c))
3784 .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
3785 .min_by(|a, b| {
3786 let da = (a.x - px).hypot(a.y - py);
3787 let db = (b.x - px).hypot(b.y - py);
3788 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3789 })
3790 .cloned()
3791 }
3792
3793 pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
3796 let filter = self.inventory_filter.as_str();
3797 match self.inventory_tab {
3798 InventoryTab::OnPerson => {
3799 let mut rows = self.carried_worn_rows_filtered(filter);
3800 rows.extend(self.person_rows_filtered(filter));
3801 rows
3802 }
3803 InventoryTab::Nearby => {
3804 let mut rows = Vec::new();
3805 for nc in self.nearby_containers() {
3806 if filter.is_empty() {
3807 rows.extend(nc.rows);
3808 continue;
3809 }
3810 let shell = nc.rows.first().cloned();
3811 let contents: Vec<_> = nc
3812 .rows
3813 .iter()
3814 .skip(1)
3815 .filter(|r| stack_matches_filter(&r.stack, filter))
3816 .cloned()
3817 .collect();
3818 let shell_hit = shell
3819 .as_ref()
3820 .map(|s| stack_matches_filter(&s.stack, filter))
3821 .unwrap_or(false);
3822 if shell_hit || !contents.is_empty() {
3823 if let Some(s) = shell {
3824 rows.push(s);
3825 }
3826 if shell_hit {
3827 rows.extend(nc.rows.into_iter().skip(1));
3828 } else {
3829 rows.extend(contents);
3830 }
3831 }
3832 }
3833 rows
3834 }
3835 }
3836 }
3837
3838 pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
3839 self.inventory_selectable_rows()
3840 .into_iter()
3841 .nth(self.inventory_menu_index)
3842 }
3843
3844 fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
3845 let cat = self
3846 .inventory_item_category(&row.stack.template_id)
3847 .unwrap_or("");
3848 if cat == "key" {
3849 self.key_inventory_label(&row.stack)
3850 } else {
3851 row.stack
3852 .display_name
3853 .clone()
3854 .unwrap_or_else(|| row.stack.template_id.clone())
3855 }
3856 }
3857
3858 fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
3860 let bindings =
3861 format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
3862 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3863 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3864 let mode = Self::grant_mode(&row.stack);
3865 format!(" [grant {effect} · {mode} — e apply]")
3866 } else {
3867 String::new()
3868 };
3869 let qty = if row.stack.quantity > 1 {
3870 format!(" ×{}", row.stack.quantity)
3871 } else {
3872 String::new()
3873 };
3874 let worn_slot = if row.is_equip_shell {
3875 match row.from {
3876 flatland_protocol::InventoryLocation::Worn { slot } => {
3877 format!(" ({})", body_slot_label(slot))
3878 }
3879 _ => String::new(),
3880 }
3881 } else {
3882 String::new()
3883 };
3884 format!("{grant_hint}{bindings}{qty}{worn_slot}")
3885 }
3886
3887 fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
3888 (
3889 row.stack.template_id.clone(),
3890 self.inventory_row_base_label(row),
3891 self.inventory_row_visible_mod_signature(row),
3892 )
3893 }
3894
3895 fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
3897 let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
3898 for row in self.inventory_selectable_rows() {
3899 if row.stack.item_instance_id.is_none() {
3900 continue;
3901 }
3902 let key = self.inventory_row_instance_identity_key(&row);
3903 *counts.entry(key).or_default() += 1;
3904 }
3905 counts
3906 .into_iter()
3907 .filter(|(_, n)| *n > 1)
3908 .map(|(k, _)| k)
3909 .collect()
3910 }
3911
3912 fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
3913 let hex: String = id
3914 .as_simple()
3915 .to_string()
3916 .chars()
3917 .filter(|c| c.is_ascii_hexdigit())
3918 .collect();
3919 let short = if hex.len() >= 4 {
3920 &hex[hex.len() - 4..]
3921 } else {
3922 hex.as_str()
3923 };
3924 format!("Instance {id} (#{short})")
3925 }
3926
3927 pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
3929 let cat = self
3930 .inventory_item_category(&row.stack.template_id)
3931 .unwrap_or("");
3932 let label = self.inventory_row_base_label(row);
3933 let hint: String = if row.is_equip_shell {
3934 " [worn — Enter to unequip]".into()
3935 } else if row.is_chest_shell {
3936 let (locked, lodging_note) = match &row.from {
3937 flatland_protocol::InventoryLocation::Placed { container_id } => {
3938 let locked = self
3939 .placed_containers
3940 .iter()
3941 .find(|c| c.id == *container_id)
3942 .map(|c| c.locked)
3943 .unwrap_or(false);
3944 let lodging_note = self
3945 .lodging_occupancy_label(container_id)
3946 .map(|who| format!(" [lodging: {who}]"))
3947 .unwrap_or_default();
3948 (locked, lodging_note)
3949 }
3950 _ => (false, String::new()),
3951 };
3952 if locked {
3953 format!(" [locked — Enter pick up · l unlock]{lodging_note}")
3954 } else {
3955 format!(" [Enter pick up · l lock]{lodging_note}")
3956 }
3957 } else if cat == "key" {
3958 self.key_inventory_hint(&row.stack)
3959 } else {
3960 match cat {
3961 "weapon" => " [weapon]".into(),
3962 "container" => " [bag/chest/belt]".into(),
3963 "lodging" => " [worker lodging]".into(),
3964 "armor" => " [armor]".into(),
3965 _ => String::new(),
3966 }
3967 };
3968 let qty = if row.stack.quantity > 1 {
3969 format!(" ×{}", row.stack.quantity)
3970 } else {
3971 String::new()
3972 };
3973 let bindings =
3974 format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
3975 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3976 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3977 let mode = Self::grant_mode(&row.stack);
3978 format!(" [grant {effect} · {mode} — e apply]")
3979 } else {
3980 String::new()
3981 };
3982 let mass = self.stack_mass(&row.stack);
3983 let mass_kg = (mass >= 0.05).then_some(mass);
3984 let mass_str = mass_kg.map(|m| format!(" {m:.1} kg")).unwrap_or_default();
3985 let volume = self.container_volume_stats(row);
3986 let vol_str = self.container_volume_label(row);
3987
3988 let mut title = label.clone();
3989 title.push_str(&qty);
3990 if row.is_equip_shell {
3991 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3992 title.push_str(&format!(" ({})", body_slot_label(slot)));
3993 }
3994 }
3995
3996 InventoryRowView {
3997 depth: row.depth,
3998 text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
3999 title: format!("{title}{grant_hint}{bindings}"),
4000 mass_kg,
4001 volume,
4002 instance_tooltip: None,
4003 }
4004 }
4005
4006 fn push_browser_item(
4007 &self,
4008 lines: &mut Vec<InventoryBrowserLine>,
4009 row: &InventoryRow,
4010 global_idx: &mut usize,
4011 target: usize,
4012 highlight: bool,
4013 ambiguous_instance_keys: &HashSet<(String, String, String)>,
4014 ) {
4015 let mut view = self.format_inventory_row(row);
4016 if let Some(id) = row.stack.item_instance_id {
4017 let key = self.inventory_row_instance_identity_key(row);
4018 if ambiguous_instance_keys.contains(&key) {
4019 view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
4020 }
4021 }
4022 lines.push(InventoryBrowserLine::Item {
4023 selectable_index: *global_idx,
4024 selected: highlight && *global_idx == target,
4025 depth: view.depth,
4026 text: view.text,
4027 title: view.title,
4028 mass_kg: view.mass_kg,
4029 volume: view.volume,
4030 instance_tooltip: view.instance_tooltip,
4031 });
4032 *global_idx += 1;
4033 }
4034
4035 pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
4038 let mut lines = Vec::new();
4039 let target = self.inventory_menu_index;
4040 let highlight = !self.show_move_picker && !self.show_grant_picker;
4041 let filter = self.inventory_filter.as_str();
4042 let mut global_idx = 0usize;
4043 let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
4044
4045 match self.inventory_tab {
4046 InventoryTab::OnPerson => {
4047 lines.push(InventoryBrowserLine::Section("— In carried bags —".into()));
4048 let carried = self.carried_worn_rows_filtered(filter);
4049 if carried.is_empty() {
4050 lines.push(InventoryBrowserLine::Hint(
4051 " (no items in carried bags)".into(),
4052 ));
4053 } else {
4054 for row in &carried {
4055 self.push_browser_item(
4056 &mut lines,
4057 row,
4058 &mut global_idx,
4059 target,
4060 highlight,
4061 &ambiguous_instance_keys,
4062 );
4063 }
4064 }
4065
4066 lines.push(InventoryBrowserLine::Blank);
4067 lines.push(InventoryBrowserLine::Section(
4068 "— On you (loose, not worn) —".into(),
4069 ));
4070 let person = self.person_rows_filtered(filter);
4071 if person.is_empty() {
4072 lines.push(InventoryBrowserLine::Hint(" (empty)".into()));
4073 } else {
4074 let mut last_group: Option<&'static str> = None;
4075 for row in &person {
4076 if row.depth == 0 {
4077 let cat = row
4078 .stack
4079 .category
4080 .as_deref()
4081 .or_else(|| self.inventory_item_category(&row.stack.template_id))
4082 .unwrap_or("");
4083 let (group, _) = inventory_category_group(cat);
4084 if last_group != Some(group) {
4085 lines.push(InventoryBrowserLine::SlotLabel(format!(" {group}")));
4086 last_group = Some(group);
4087 }
4088 }
4089 self.push_browser_item(
4090 &mut lines,
4091 row,
4092 &mut global_idx,
4093 target,
4094 highlight,
4095 &ambiguous_instance_keys,
4096 );
4097 }
4098 }
4099 }
4100 InventoryTab::Nearby => {
4101 let nearby = self.nearby_containers();
4102 if nearby.is_empty() {
4103 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
4104 lines.push(InventoryBrowserLine::Hint(
4105 " (none within reach — walk up to a chest)".into(),
4106 ));
4107 lines.push(InventoryBrowserLine::Hint(
4108 " Select an on-person item, then m / Enter → move into chest.".into(),
4109 ));
4110 } else {
4111 let mut any_visible = false;
4112 for nc in &nearby {
4113 let shell = nc.rows.first();
4114 let contents: Vec<&InventoryRow> = if filter.is_empty() {
4115 nc.rows.iter().skip(1).collect()
4116 } else {
4117 let shell_hit = shell
4118 .map(|s| {
4119 let f = filter.to_ascii_lowercase();
4120 let name = s
4121 .stack
4122 .display_name
4123 .as_deref()
4124 .unwrap_or("")
4125 .to_ascii_lowercase();
4126 let tid = s.stack.template_id.to_ascii_lowercase();
4127 name.contains(&f) || tid.contains(&f)
4128 })
4129 .unwrap_or(false);
4130 if shell_hit {
4131 nc.rows.iter().skip(1).collect()
4132 } else {
4133 nc.rows
4134 .iter()
4135 .skip(1)
4136 .filter(|r| stack_matches_filter(&r.stack, filter))
4137 .collect()
4138 }
4139 };
4140 let shell_visible = filter.is_empty()
4141 || shell
4142 .map(|s| stack_matches_filter(&s.stack, filter))
4143 .unwrap_or(false)
4144 || !contents.is_empty();
4145 if !shell_visible && shell.is_some() {
4146 continue;
4147 }
4148 any_visible = true;
4149 lines.push(InventoryBrowserLine::Blank);
4150 let lock_note = if nc.view.locked && nc.view.accessible {
4151 " unlocked with your key"
4152 } else if nc.view.locked {
4153 " locked"
4154 } else {
4155 ""
4156 };
4157 lines.push(InventoryBrowserLine::Section(format!(
4158 "— {} ({:.0}m away){lock_note} —",
4159 nc.view.display_name, nc.distance_m
4160 )));
4161 if !nc.view.accessible {
4162 lines.push(InventoryBrowserLine::Hint(
4163 " locked — need the matching key (l to try)".into(),
4164 ));
4165 } else if nc.rows.is_empty() {
4166 lines.push(InventoryBrowserLine::Hint(
4167 " (empty — switch to On person, select an item, m to move in)"
4168 .into(),
4169 ));
4170 } else if let Some(shell_row) = shell {
4171 self.push_browser_item(
4172 &mut lines,
4173 shell_row,
4174 &mut global_idx,
4175 target,
4176 highlight,
4177 &ambiguous_instance_keys,
4178 );
4179 for row in contents {
4180 self.push_browser_item(
4181 &mut lines,
4182 row,
4183 &mut global_idx,
4184 target,
4185 highlight,
4186 &ambiguous_instance_keys,
4187 );
4188 }
4189 }
4190 }
4191 if !any_visible {
4192 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
4193 lines.push(InventoryBrowserLine::Hint(
4194 " (no matching items — clear filter with Esc)".into(),
4195 ));
4196 }
4197 }
4198 }
4199 }
4200 lines
4201 }
4202
4203 pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
4205 let mut opts = Vec::new();
4206 opts.push(MoveOption {
4207 label: "Relocate…".into(),
4208 kind: MoveOptionKind::RelocatePlaced {
4209 container_id: container_id.to_string(),
4210 },
4211 });
4212 opts.push(MoveOption {
4213 label: "On your person (loose)".into(),
4214 kind: MoveOptionKind::PickupPlaced {
4215 container_id: container_id.to_string(),
4216 nest_location: flatland_protocol::InventoryLocation::Root,
4217 nest_parent_instance_id: None,
4218 },
4219 });
4220 for (slot, item) in &self.worn {
4221 if item.category.as_deref() != Some("container") {
4222 continue;
4223 }
4224 if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
4225 continue;
4226 }
4227 let Some(parent_id) = item.item_instance_id else {
4228 continue;
4229 };
4230 let shell_name = item
4231 .display_name
4232 .clone()
4233 .unwrap_or_else(|| item.template_id.clone());
4234 opts.push(MoveOption {
4235 label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
4236 kind: MoveOptionKind::PickupPlaced {
4237 container_id: container_id.to_string(),
4238 nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
4239 nest_parent_instance_id: Some(parent_id),
4240 },
4241 });
4242 Self::append_chest_pickup_nested(
4244 &mut opts,
4245 container_id,
4246 flatland_protocol::InventoryLocation::Worn { slot: *slot },
4247 item,
4248 &format!("in {shell_name}"),
4249 );
4250 }
4251 opts.push(MoveOption {
4252 label: "Cancel".into(),
4253 kind: MoveOptionKind::Cancel,
4254 });
4255 opts
4256 }
4257
4258 fn append_chest_pickup_nested(
4259 opts: &mut Vec<MoveOption>,
4260 container_id: &str,
4261 location: flatland_protocol::InventoryLocation,
4262 parent: &flatland_protocol::ItemStack,
4263 context: &str,
4264 ) {
4265 for child in &parent.contents {
4266 if child.category.as_deref() != Some("container") {
4267 continue;
4268 }
4269 if !Self::is_volume_container_stack(child) {
4270 continue;
4271 }
4272 if child.world_placeable == Some(true) {
4274 continue;
4275 }
4276 let Some(child_id) = child.item_instance_id else {
4277 continue;
4278 };
4279 let name = child
4280 .display_name
4281 .clone()
4282 .unwrap_or_else(|| child.template_id.clone());
4283 opts.push(MoveOption {
4284 label: format!("{name} ({context})"),
4285 kind: MoveOptionKind::PickupPlaced {
4286 container_id: container_id.to_string(),
4287 nest_location: location.clone(),
4288 nest_parent_instance_id: Some(child_id),
4289 },
4290 });
4291 Self::append_chest_pickup_nested(
4292 opts,
4293 container_id,
4294 location.clone(),
4295 child,
4296 &format!("in {name}"),
4297 );
4298 }
4299 }
4300
4301 pub fn move_destinations_for(
4303 &self,
4304 from: &flatland_protocol::InventoryLocation,
4305 from_parent_instance_id: Option<uuid::Uuid>,
4306 moving_instance_id: Option<uuid::Uuid>,
4307 moving_template_id: &str,
4308 ) -> Vec<MoveOption> {
4309 let mut opts = Vec::new();
4310 if *from != flatland_protocol::InventoryLocation::Root {
4311 opts.push(MoveOption {
4312 label: "On your person (loose)".into(),
4313 kind: MoveOptionKind::Move {
4314 location: flatland_protocol::InventoryLocation::Root,
4315 parent_instance_id: None,
4316 },
4317 });
4318 }
4319 for (slot, item) in &self.worn {
4320 if item.category.as_deref() != Some("container") {
4321 continue;
4322 }
4323 let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4324 let shell_name = item
4325 .display_name
4326 .clone()
4327 .unwrap_or_else(|| item.template_id.clone());
4328
4329 if *slot != BodySlot::Waist
4331 && item.item_instance_id != moving_instance_id
4332 && Self::is_volume_container_stack(item)
4333 {
4334 Self::push_move_destination(
4335 &mut opts,
4336 format!("{shell_name} (worn {})", body_slot_label(*slot)),
4337 location.clone(),
4338 item.item_instance_id,
4339 from,
4340 from_parent_instance_id,
4341 );
4342 }
4343
4344 if *slot == BodySlot::Waist
4346 && Self::attaches_to_belt_loop(moving_template_id)
4347 && item.item_instance_id != moving_instance_id
4348 {
4349 Self::push_move_destination(
4350 &mut opts,
4351 format!("{shell_name} (belt loop)"),
4352 location.clone(),
4353 item.item_instance_id,
4354 from,
4355 from_parent_instance_id,
4356 );
4357 }
4358
4359 let context = if *slot == BodySlot::Waist {
4360 format!("on {shell_name}")
4361 } else {
4362 format!("in {shell_name}")
4363 };
4364 Self::append_nested_container_destinations(
4365 &mut opts,
4366 location,
4367 item,
4368 &context,
4369 from,
4370 from_parent_instance_id,
4371 moving_instance_id,
4372 );
4373 }
4374 for nc in self.nearby_containers() {
4375 if !nc.view.accessible {
4376 continue;
4377 }
4378 let location = flatland_protocol::InventoryLocation::Placed {
4379 container_id: nc.view.id.clone(),
4380 };
4381 Self::push_move_destination(
4382 &mut opts,
4383 format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
4384 location,
4385 nc.view.item_instance_id,
4386 from,
4387 from_parent_instance_id,
4388 );
4389 }
4390 let allow_drop = moving_instance_id
4391 .map(|id| !self.hand_equipped_instance_ids().contains(&id))
4392 .unwrap_or(true)
4393 && moving_instance_id
4394 .and_then(|id| self.stack_for_instance(id))
4395 .map(|stack| {
4396 !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
4397 })
4398 .unwrap_or(
4399 moving_template_id != KEY_TEMPLATE
4400 && moving_template_id != PROPERTY_DEED_TEMPLATE,
4401 );
4402 if allow_drop {
4403 opts.push(MoveOption {
4404 label: "Drop on the ground".into(),
4405 kind: MoveOptionKind::Drop,
4406 });
4407 }
4408 opts.push(MoveOption {
4409 label: "Cancel".into(),
4410 kind: MoveOptionKind::Cancel,
4411 });
4412 opts
4413 }
4414
4415 fn is_same_container_dest(
4416 dest_location: &flatland_protocol::InventoryLocation,
4417 dest_parent: Option<uuid::Uuid>,
4418 from: &flatland_protocol::InventoryLocation,
4419 from_parent: Option<uuid::Uuid>,
4420 ) -> bool {
4421 dest_location == from && dest_parent == from_parent
4422 }
4423
4424 fn push_move_destination(
4425 opts: &mut Vec<MoveOption>,
4426 label: String,
4427 location: flatland_protocol::InventoryLocation,
4428 parent_instance_id: Option<uuid::Uuid>,
4429 from: &flatland_protocol::InventoryLocation,
4430 from_parent_instance_id: Option<uuid::Uuid>,
4431 ) {
4432 if Self::is_same_container_dest(
4433 &location,
4434 parent_instance_id,
4435 from,
4436 from_parent_instance_id,
4437 ) {
4438 return;
4439 }
4440 opts.push(MoveOption {
4441 label,
4442 kind: MoveOptionKind::Move {
4443 location,
4444 parent_instance_id,
4445 },
4446 });
4447 }
4448
4449 fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
4450 stack.capacity_volume.is_some_and(|c| c > 0.0)
4451 }
4452
4453 fn attaches_to_belt_loop(template_id: &str) -> bool {
4454 matches!(template_id, "leather_pouch" | "dimensional_pouch")
4455 }
4456
4457 fn append_nested_container_destinations(
4458 opts: &mut Vec<MoveOption>,
4459 location: flatland_protocol::InventoryLocation,
4460 container: &flatland_protocol::ItemStack,
4461 context: &str,
4462 from: &flatland_protocol::InventoryLocation,
4463 from_parent_instance_id: Option<uuid::Uuid>,
4464 moving_instance_id: Option<uuid::Uuid>,
4465 ) {
4466 for child in &container.contents {
4467 if Self::is_volume_container_stack(child)
4468 && child.item_instance_id != moving_instance_id
4469 {
4470 let name = child
4471 .display_name
4472 .clone()
4473 .unwrap_or_else(|| child.template_id.clone());
4474 Self::push_move_destination(
4475 opts,
4476 format!("{name} ({context})"),
4477 location.clone(),
4478 child.item_instance_id,
4479 from,
4480 from_parent_instance_id,
4481 );
4482 }
4483 let nested_context = format!(
4484 "in {}",
4485 child.display_name.as_deref().unwrap_or(&child.template_id)
4486 );
4487 Self::append_nested_container_destinations(
4488 opts,
4489 location.clone(),
4490 child,
4491 &nested_context,
4492 from,
4493 from_parent_instance_id,
4494 moving_instance_id,
4495 );
4496 }
4497 }
4498
4499 fn clamp_inventory_indices(&mut self) {
4500 let n = self.inventory_selectable_rows().len();
4501 self.inventory_menu_index = if n == 0 {
4502 0
4503 } else {
4504 self.inventory_menu_index.min(n - 1)
4505 };
4506 if let Some(picker) = &self.move_picker {
4507 let pn = picker.options.len();
4508 self.move_picker_index = if pn == 0 {
4509 0
4510 } else {
4511 self.move_picker_index.min(pn - 1)
4512 };
4513 }
4514 }
4515
4516 fn sync_interior_map_context(&mut self) {
4521 if self.effective_inside_building().is_none() {
4522 self.interior_map = None;
4523 if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
4524 self.z_platforms = platforms;
4525 self.z_transitions = transitions;
4526 }
4527 return;
4528 }
4529 self.sync_interior_z_bands();
4530 }
4531
4532 fn sync_interior_z_bands(&mut self) {
4534 if self.effective_inside_building().is_some() {
4535 if let Some(map) = &self.interior_map {
4536 if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
4537 if self.z_bands_outdoor_backup.is_none() {
4538 self.z_bands_outdoor_backup = Some((
4539 std::mem::take(&mut self.z_platforms),
4540 std::mem::take(&mut self.z_transitions),
4541 ));
4542 }
4543 self.z_platforms = map.z_platforms.clone();
4544 self.z_transitions = map.z_transitions.clone();
4545 }
4546 }
4547 }
4548 }
4549
4550 fn apply_snapshot_fields(
4551 &mut self,
4552 snapshot: &flatland_protocol::Snapshot,
4553 entity_id: EntityId,
4554 ) {
4555 self.tick = snapshot.tick;
4556 self.chunk_rev = snapshot.chunk_rev;
4557 self.content_rev = snapshot.content_rev;
4558 self.publish_rev = snapshot.publish_rev;
4559 self.resource_nodes = snapshot.resource_nodes.clone();
4560 self.ground_drops = snapshot.ground_drops.clone();
4561 self.placed_containers = snapshot.placed_containers.clone();
4562 self.world_x0 = snapshot.world_x0;
4563 self.world_y0 = snapshot.world_y0;
4564 self.world_width_m = snapshot.world_width_m;
4565 self.world_height_m = snapshot.world_height_m;
4566 self.world_clock = snapshot.world_clock;
4567 self.terrain_zones = snapshot.terrain_zones.clone();
4568 self.z_platforms = snapshot.z_platforms.clone();
4569 self.z_transitions = snapshot.z_transitions.clone();
4570 self.z_bands_outdoor_backup = None;
4572 self.buildings = snapshot.buildings.clone();
4573 self.doors = snapshot.doors.clone();
4574 self.interior_map = snapshot.interior_map.clone();
4575 self.npcs = snapshot.npcs.clone();
4576 self.blueprints = snapshot.blueprints.clone();
4577 self.building_materials = snapshot.building_materials.clone();
4578 self.sync_inventory_from_stacks(&snapshot.inventory);
4579 self.player = snapshot
4580 .entities
4581 .iter()
4582 .find(|e| e.id == entity_id)
4583 .cloned();
4584 self.entities = snapshot.entities.clone();
4585 self.quest_log = snapshot.quest_log.clone();
4586 self.apply_hired_workers(snapshot.hired_workers.clone());
4587 self.interactables = snapshot.interactables.clone();
4588 self.ledger = snapshot.ledger.clone();
4589 self.career = snapshot.career.clone();
4590 self.combat_fx = snapshot.combat_fx.clone();
4591 self.ground_hazards = snapshot.ground_hazards.clone();
4592 self.property_zones = snapshot.property_zones.clone();
4593 self.tax_zones = snapshot.tax_zones.clone();
4594 self.growth_zones = snapshot.growth_zones.clone();
4595 self.biome_zones = snapshot.biome_zones.clone();
4596 self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
4597 self.property_plots = snapshot.property_plots.clone();
4598 self.property_plot_settings = snapshot.property_plot_settings.clone();
4599 if self.effective_inside_building().is_some() {
4602 self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
4603 }
4604 self.sync_interior_map_context();
4605 self.refresh_whisper_range();
4606 self.sync_gameplay_audio();
4607 }
4608
4609 fn refresh_inventory_ui(&mut self) {
4613 if let Some(picker) = &self.move_picker {
4614 let instance_id = picker.item_instance_id;
4615 let still_exists = self
4616 .inventory_selectable_rows()
4617 .iter()
4618 .any(|r| r.stack.item_instance_id == Some(instance_id));
4619 if !still_exists {
4620 self.move_picker = None;
4621 self.show_move_picker = false;
4622 }
4623 }
4624 if let Some(picker) = &self.destroy_picker {
4625 let instance_id = picker.item_instance_id;
4626 let still_exists = self
4627 .inventory_selectable_rows()
4628 .iter()
4629 .any(|r| r.stack.item_instance_id == Some(instance_id));
4630 if !still_exists {
4631 self.destroy_picker = None;
4632 self.show_destroy_picker = false;
4633 self.destroy_confirm_pending = false;
4634 }
4635 }
4636 self.clamp_inventory_indices();
4637 }
4638
4639 fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
4645 let selected_id = self
4646 .hired_workers
4647 .get(self.workers_menu_index)
4648 .map(|w| w.instance_id.clone());
4649 let previous_worker_ids: HashSet<String> = self
4650 .hired_workers
4651 .iter()
4652 .map(|worker| worker.instance_id.clone())
4653 .collect();
4654 workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
4655 let now = Instant::now();
4656 let saw_new_worker = workers
4657 .iter()
4658 .any(|worker| !previous_worker_ids.contains(&worker.instance_id));
4659 for worker in &workers {
4660 let was_hit = self
4661 .hired_workers
4662 .iter()
4663 .find(|previous| previous.instance_id == worker.instance_id)
4664 .is_some_and(|previous| {
4665 matches!(worker.mode, flatland_protocol::WorkerModeView::Defender)
4666 && worker.vitals.health_pct + 0.01 < previous.vitals.health_pct
4667 });
4668 if was_hit {
4669 self.worker_health_ring_until
4670 .insert(worker.entity_id, now + WORKER_HEALTH_RING_HOLD);
4671 }
4672 }
4673 let worker_entity_ids: HashSet<EntityId> =
4674 workers.iter().map(|worker| worker.entity_id).collect();
4675 self.worker_health_ring_until
4676 .retain(|entity_id, _| worker_entity_ids.contains(entity_id));
4677 for w in &workers {
4678 let prev_err = self
4679 .hired_workers
4680 .iter()
4681 .find(|p| p.instance_id == w.instance_id)
4682 .and_then(|p| p.last_error.as_deref());
4683 let new_err = w.last_error.as_deref();
4684 if new_err != prev_err {
4685 if let Some(err) = new_err {
4686 if !worker_error_is_transient(err) {
4687 self.push_log(format!("Worker {}: {err}", w.label));
4688 }
4689 }
4690 }
4691 }
4692 let mut next_display = BTreeMap::new();
4693 let mut next_errors = BTreeMap::new();
4694 for w in &workers {
4695 let mut sticky = self
4696 .worker_step_display
4697 .remove(&w.instance_id)
4698 .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
4699 sticky.observe(&w.step_label, now);
4700 next_display.insert(w.instance_id.clone(), sticky);
4701
4702 let mut err_sticky = self
4703 .worker_error_display
4704 .remove(&w.instance_id)
4705 .unwrap_or_default();
4706 err_sticky.observe(w.last_error.as_deref(), now);
4707 if err_sticky.shown(now).is_some() {
4708 next_errors.insert(w.instance_id.clone(), err_sticky);
4709 }
4710 }
4711 self.worker_step_display = next_display;
4712 self.worker_error_display = next_errors;
4713 self.hired_workers = workers;
4714 if saw_new_worker {
4715 self.pending_worker_hire_since = None;
4716 }
4717 self.sync_worker_take_picker_from_hired();
4718 if let Some(id) = selected_id {
4719 if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
4720 self.workers_menu_index = idx;
4721 return;
4722 }
4723 }
4724 if self.workers_menu_index >= self.hired_workers.len() {
4725 self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
4726 }
4727 }
4728
4729 fn sync_worker_take_picker_from_hired(&mut self) {
4731 if !self.show_worker_take_picker {
4732 return;
4733 }
4734 let Some(picker) = self.worker_take_picker.clone() else {
4735 return;
4736 };
4737 let Some(worker) = self
4738 .hired_workers
4739 .iter()
4740 .find(|w| w.instance_id == picker.worker_instance_id)
4741 .cloned()
4742 else {
4743 self.show_worker_take_picker = false;
4744 self.worker_take_picker = None;
4745 self.worker_take_picker_index = 0;
4746 return;
4747 };
4748 let options: Vec<WorkerGiveOption> = worker
4749 .inventory
4750 .iter()
4751 .filter_map(|stack| {
4752 let item_instance_id = stack.item_instance_id?;
4753 let label = stack
4754 .display_name
4755 .clone()
4756 .unwrap_or_else(|| stack.template_id.clone());
4757 let label = if stack.quantity > 1 {
4758 format!("{label} ×{}", stack.quantity)
4759 } else {
4760 label
4761 };
4762 Some(WorkerGiveOption {
4763 item_instance_id,
4764 label,
4765 quantity: stack.quantity,
4766 template_id: stack.template_id.clone(),
4767 })
4768 })
4769 .collect();
4770 if options.is_empty() {
4771 self.show_worker_take_picker = false;
4772 self.worker_take_picker = None;
4773 self.worker_take_picker_index = 0;
4774 return;
4775 }
4776 let prev_id = picker
4777 .options
4778 .get(self.worker_take_picker_index)
4779 .map(|o| o.item_instance_id);
4780 let idx = prev_id
4781 .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
4782 .unwrap_or(0)
4783 .min(options.len().saturating_sub(1));
4784 let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
4785 let quantity = picker.quantity.clamp(1, max_qty);
4786 self.worker_take_picker_index = idx;
4787 self.worker_take_picker = Some(WorkerTakePicker {
4788 worker_instance_id: picker.worker_instance_id,
4789 worker_label: picker.worker_label,
4790 options,
4791 quantity,
4792 });
4793 }
4794
4795 pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
4797 self.worker_step_display
4798 .get(worker_instance_id)
4799 .map(|s| s.shown.as_str())
4800 .or_else(|| {
4801 self.hired_workers
4802 .iter()
4803 .find(|w| w.instance_id == worker_instance_id)
4804 .map(|w| w.step_label.as_str())
4805 })
4806 .unwrap_or("")
4807 }
4808
4809 pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
4811 let now = Instant::now();
4812 self.worker_error_display
4813 .get(worker_instance_id)
4814 .and_then(|s| s.shown(now))
4815 .or_else(|| {
4816 self.hired_workers
4817 .iter()
4818 .find(|w| w.instance_id == worker_instance_id)
4819 .and_then(|w| w.last_error.as_deref())
4820 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
4821 })
4822 .filter(|e| !worker_error_is_hud_noise(e))
4823 }
4824
4825 fn apply_combat_hud(&mut self, combat: &CombatHud) {
4826 self.in_combat = combat.in_combat;
4827 self.auto_attack = combat.auto_attack;
4828 self.combat_has_los = combat.has_los;
4829 self.attack_cd_ticks = combat.attack_cd_ticks;
4830 self.gcd_ticks = combat.gcd_ticks;
4831 self.weapon_ability_id = combat.ability_id.clone();
4832 self.mainhand_template_id = combat.mainhand_template_id.clone();
4833 self.mainhand_label = combat.mainhand_label.clone();
4834 self.mainhand_instance_id = combat.mainhand_instance_id;
4835 self.offhand_template_id = combat.offhand_template_id.clone();
4836 self.offhand_label = combat.offhand_label.clone();
4837 self.offhand_instance_id = combat.offhand_instance_id;
4838 self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
4839 1
4840 } else {
4841 combat.mainhand_hand_slots
4842 };
4843 self.defense = combat.defense.clone();
4844 self.worn = combat.worn.iter().cloned().collect();
4845 self.carry_mass = combat.carry_mass;
4846 self.carry_mass_max = combat.carry_mass_max;
4847 self.encumbrance = combat.encumbrance;
4848 self.cast_progress = combat.cast.clone();
4849 self.timed_channel = combat.timed_channel.clone();
4850 self.plot_build_offer = combat.plot_build.clone();
4851 self.ability_cooldowns = combat.ability_cooldowns.clone();
4852 self.blocking_active = combat.blocking_active;
4853 self.max_target_slots = combat.max_target_slots.max(1);
4854 self.combat_slots = combat.slots.clone();
4855 self.rotation_presets = combat.rotation_presets.clone();
4856 self.known_abilities = combat.known_abilities.clone();
4857 self.ability_meta = combat
4858 .ability_meta
4859 .iter()
4860 .cloned()
4861 .map(|meta| (meta.id.clone(), meta))
4862 .collect();
4863 self.ability_mastery = combat
4864 .ability_mastery
4865 .iter()
4866 .cloned()
4867 .map(|row| (row.ability_id.clone(), row))
4868 .collect();
4869 self.hotbar = combat.hotbar.clone();
4870 self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
4871 self.keychain_stacks = combat.keychain.clone();
4872 self.whisper_pouch_stacks = combat.whisper_pouch.clone();
4873 self.combat_target_detail = combat.target.clone();
4874 self.statuses = combat.statuses.clone();
4875 self.combat_target = combat.target_entity_id;
4876 if combat.progression_xp_base > 0.0 {
4877 self.progression_curve = Some(flatland_protocol::ProgressionCurve {
4878 baseline_display: combat.progression_baseline,
4879 xp_base: combat.progression_xp_base,
4880 xp_growth: combat.progression_xp_growth,
4881 });
4882 }
4883 if let Some(xp) = &combat.progression_xp {
4884 if let Some(player) = &mut self.player {
4885 player.progression_xp = Some(xp.clone());
4886 if let Some(attrs) = combat.attributes {
4887 player.attributes = Some(attrs);
4888 }
4889 if let Some(skills) = &combat.skills {
4890 player.skills = Some(skills.clone());
4891 }
4892 }
4893 }
4894 if let Some(label) = &combat.target_label {
4895 self.combat_target_label = Some(label.clone());
4896 } else if let Some(id) = combat.target_entity_id {
4897 self.combat_target_label = self
4898 .entities
4899 .iter()
4900 .find(|e| e.id == id)
4901 .map(|e| e.label.clone())
4902 .or_else(|| self.combat_target_label.clone());
4903 }
4904 self.refresh_inventory_ui();
4905 }
4906
4907 pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
4909 self.combat_slots
4910 .iter()
4911 .find(|s| s.slot_index == slot)
4912 .and_then(|s| s.target_entity_id)
4913 .or_else(|| if slot == 1 { self.combat_target } else { None })
4914 }
4915
4916 pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
4918 self.ability_meta
4919 .get(ability_id)
4920 .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
4921 .unwrap_or(self.ground_target.is_some())
4924 }
4925
4926 pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
4928 self.ability_meta
4929 .get(ability_id)
4930 .map(|meta| meta.aim_mode == "ground")
4931 .unwrap_or(false)
4932 }
4933
4934 pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
4937 self.ability_meta
4938 .get(ability_id)
4939 .map(|meta| meta.auto_rotation_eligible)
4940 .unwrap_or(true)
4941 }
4942
4943 pub fn set_ground_target(&mut self, x: f32, y: f32) {
4945 self.ground_target = Some((x, y, 0.0));
4946 }
4947
4948 pub fn clear_ground_target(&mut self) {
4950 self.ground_target = None;
4951 }
4952
4953 pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
4956 if !(1..=9).contains(&slot_1_to_9) {
4957 return None;
4958 }
4959 self.hotbar
4960 .get((slot_1_to_9 - 1) as usize)
4961 .and_then(|a| a.as_deref())
4962 .filter(|id| !id.is_empty())
4963 }
4964
4965 pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
4967 let binding = self.hotbar_ability(slot_1_to_9)?;
4968 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
4969 let name = self
4970 .inventory_hints
4971 .get(template_id)
4972 .map(|h| h.display_name.as_str())
4973 .unwrap_or(template_id);
4974 let qty = self.inventory.get(template_id).copied().unwrap_or(0);
4975 Some(format!("{name}×{qty}"))
4976 } else {
4977 Some(binding.to_string())
4978 }
4979 }
4980
4981 pub fn loadout_ability_choices(&self) -> Vec<String> {
4983 let mut out = self.known_abilities.clone();
4984 let weapon = self.weapon_ability_id.trim();
4985 if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
4986 out.push(weapon.to_string());
4987 }
4988 out
4989 }
4990
4991 pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
4993 let mut out = Vec::new();
4994 for ability in self.loadout_ability_choices() {
4995 let meta = if ability == self.weapon_ability_id {
4996 Some("weapon".into())
4997 } else {
4998 None
4999 };
5000 out.push(LoadoutHotbarChoice {
5001 binding: ability.clone(),
5002 label: ability,
5003 meta,
5004 });
5005 }
5006 let mut consumables: Vec<(String, String, u32)> = Vec::new();
5007 for stack in &self.inventory_stacks {
5008 if Self::stack_is_item_grant(stack) {
5009 continue;
5010 }
5011 if self.inventory_item_category(&stack.template_id) != Some("consumable") {
5012 continue;
5013 }
5014 let qty = stack.quantity.max(1);
5015 if let Some((_, _, existing)) = consumables
5016 .iter_mut()
5017 .find(|(id, _, _)| id == &stack.template_id)
5018 {
5019 *existing = existing.saturating_add(qty);
5020 } else {
5021 let label = stack
5022 .display_name
5023 .clone()
5024 .or_else(|| {
5025 self.inventory_hints
5026 .get(&stack.template_id)
5027 .map(|h| h.display_name.clone())
5028 })
5029 .unwrap_or_else(|| stack.template_id.clone());
5030 consumables.push((stack.template_id.clone(), label, qty));
5031 }
5032 }
5033 consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
5034 for (template_id, label, qty) in consumables {
5035 out.push(LoadoutHotbarChoice {
5036 binding: flatland_protocol::hotbar_consumable_binding(&template_id),
5037 label: format!("{label} ×{qty}"),
5038 meta: Some("use".into()),
5039 });
5040 }
5041 out
5042 }
5043
5044 pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
5046 self.combat_candidates()
5047 }
5048
5049 pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
5051 let (px, py) = self.player_position();
5052 let dist = |id: EntityId| {
5053 self.entities
5054 .iter()
5055 .find(|e| e.id == id)
5056 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
5057 .unwrap_or(f32::MAX)
5058 };
5059
5060 let mut allies = Vec::new();
5061 if let Some(me) = self.player.as_ref() {
5063 let alive = me
5064 .vitals
5065 .as_ref()
5066 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
5067 .unwrap_or(true);
5068 if alive {
5069 allies.push((self.entity_id, "Yourself".into()));
5070 }
5071 }
5072 for entity in &self.entities {
5073 if entity.id == self.entity_id {
5074 continue;
5075 }
5076 if entity.vitals.is_some() {
5077 let alive = entity
5078 .vitals
5079 .as_ref()
5080 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
5081 .unwrap_or(true);
5082 if alive {
5083 allies.push((entity.id, entity.label.clone()));
5084 }
5085 }
5086 }
5087 allies.sort_by(|(a, _), (b, _)| {
5088 if *a == self.entity_id {
5089 return std::cmp::Ordering::Less;
5090 }
5091 if *b == self.entity_id {
5092 return std::cmp::Ordering::Greater;
5093 }
5094 dist(*a)
5095 .partial_cmp(&dist(*b))
5096 .unwrap_or(std::cmp::Ordering::Equal)
5097 });
5098
5099 let mut monsters = self.combat_candidates();
5100 monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
5101 allies.into_iter().chain(monsters).collect()
5102 }
5103
5104 fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
5105 match slot_index {
5106 2 => self.t2_candidates(),
5107 _ => self.t1_candidates(),
5108 }
5109 }
5110
5111 pub fn pick_combat_target_at(
5113 &self,
5114 wx: f32,
5115 wy: f32,
5116 slot_index: u8,
5117 radius_m: f32,
5118 ) -> Option<(EntityId, String)> {
5119 let mut best: Option<(f32, EntityId, String)> = None;
5120 for (id, label) in self.candidates_for_slot(slot_index) {
5121 let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
5122 if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
5124 let d = distance(wx, wy, npc.x, npc.y);
5125 if d <= radius_m {
5126 best = match best {
5127 Some((bd, _, _)) if bd <= d => best,
5128 _ => Some((d, id, label)),
5129 };
5130 }
5131 }
5132 continue;
5133 };
5134 let d = distance(
5135 wx,
5136 wy,
5137 entity.transform.position.x,
5138 entity.transform.position.y,
5139 );
5140 if d <= radius_m {
5141 best = match best {
5142 Some((bd, _, _)) if bd <= d => best,
5143 _ => Some((d, id, label)),
5144 };
5145 }
5146 }
5147 best.map(|(_, id, label)| (id, label))
5148 }
5149
5150 pub(crate) fn restore_from_welcome(
5152 &mut self,
5153 session_id: SessionId,
5154 entity_id: EntityId,
5155 snapshot: &flatland_protocol::Snapshot,
5156 ) {
5157 self.clear_harvest_state();
5158 self.disconnect_reason = None;
5159 self.show_stats = false;
5160 self.show_craft_menu = false;
5161 self.show_shop_menu = false;
5162 self.shop_catalog = None;
5163 self.show_inventory_menu = false;
5164 self.session_id = session_id;
5165 self.entity_id = entity_id;
5166 self.connected = true;
5167 self.apply_snapshot_fields(snapshot, entity_id);
5168 if let Some(combat) = &snapshot.combat {
5169 self.apply_combat_hud(combat);
5170 let stacks = self.inventory_stacks.clone();
5171 self.sync_inventory_from_stacks(&stacks);
5172 }
5173 }
5174
5175 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
5176 self.tick = delta.tick;
5177 self.world_clock = delta.world_clock;
5178
5179 if delta.entities.is_empty() {
5181 self.ground_drops = delta.ground_drops.clone();
5182 self.combat_fx = delta.combat_fx.clone();
5183 self.ground_hazards = delta.ground_hazards.clone();
5184 self.property_plots = delta.property_plots.clone();
5185 self.apply_terrain_overlays(&delta.terrain_overlays);
5186 if let Some(combat) = &delta.combat {
5187 self.apply_combat_hud(combat);
5188 let stacks = self.inventory_stacks.clone();
5189 self.sync_inventory_from_stacks(&stacks);
5190 }
5191 self.refresh_whisper_range();
5193 self.sync_gameplay_audio();
5194 return;
5195 }
5196 if !delta.buildings.is_empty() {
5197 self.buildings = delta.buildings.clone();
5198 }
5199 if !delta.blueprints.is_empty() {
5200 self.blueprints = delta.blueprints.clone();
5201 }
5202 if !delta.building_materials.is_empty() {
5203 self.building_materials = delta.building_materials.clone();
5204 }
5205 self.sync_inventory_from_stacks(&delta.inventory);
5206
5207 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
5208 self.player = Some(updated.clone());
5209 }
5210 self.entities = delta.entities.clone();
5211 if self.player.is_none() {
5212 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
5213 }
5214
5215 self.sync_interior_map_context();
5216
5217 if !delta.resource_nodes.is_empty() {
5221 self.resource_nodes = delta.resource_nodes.clone();
5222 } else if delta.interior_map.is_some() || self.effective_inside_building().is_some() {
5223 self.resource_nodes = delta.resource_nodes.clone();
5224 }
5225 self.ground_drops = delta.ground_drops.clone();
5226 self.placed_containers = delta.placed_containers.clone();
5228 if !delta.doors.is_empty() {
5229 self.doors = delta.doors.clone();
5230 }
5231 if self.effective_inside_building().is_some() {
5232 if let Some(map) = &delta.interior_map {
5233 self.interior_map = Some(map.clone());
5234 }
5235 } else {
5236 self.interior_map = None;
5237 }
5238 self.sync_interior_z_bands();
5239 self.npcs = delta.npcs.clone();
5241 if !delta.quest_log.is_empty() {
5242 self.quest_log = delta.quest_log.clone();
5243 }
5244 self.apply_hired_workers(delta.hired_workers.clone());
5245 if !delta.interactables.is_empty() {
5246 self.interactables = delta.interactables.clone();
5247 }
5248 if delta.ledger.is_some() {
5249 self.ledger = delta.ledger.clone();
5250 }
5251 if delta.career.is_some() {
5252 self.career = delta.career.clone();
5253 }
5254 self.combat_fx = delta.combat_fx.clone();
5255 self.ground_hazards = delta.ground_hazards.clone();
5256 if !delta.property_plots.is_empty() {
5258 self.property_plots = delta.property_plots.clone();
5259 }
5260 self.apply_terrain_overlays(&delta.terrain_overlays);
5261 if let Some(combat) = &delta.combat {
5262 self.apply_combat_hud(combat);
5263 let stacks = self.inventory_stacks.clone();
5264 self.sync_inventory_from_stacks(&stacks);
5265 } else {
5266 self.refresh_inventory_ui();
5267 }
5268 self.refresh_whisper_range();
5269 self.sync_gameplay_audio();
5270 }
5271
5272 fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
5275 self.terrain_zones.retain(|z| !z.id.starts_with("rt:"));
5276 self.terrain_zones.extend(overlays.iter().cloned());
5277 }
5278
5279 fn refresh_whisper_range(&mut self) {
5282 let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
5283 return;
5284 };
5285 let (px, py) = self.player_position();
5286 let in_range = self.entities.iter().any(|e| {
5287 e.id == peer
5288 && distance(px, py, e.transform.position.x, e.transform.position.y)
5289 <= INTERACTION_RADIUS_M
5290 });
5291 if !in_range {
5292 self.social_chat.cancel_whisper_out_of_range();
5293 }
5294 }
5295
5296 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
5298 let (px, py) = self.player_position();
5299 let mut out = Vec::new();
5300 for npc in &self.npcs {
5301 let Some(eid) = npc.entity_id else {
5302 continue;
5303 };
5304 let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
5305 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
5306 if alive && has_hp {
5307 out.push((eid, npc.label.clone()));
5308 }
5309 }
5310 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
5311 let dist = |id: EntityId| {
5312 self.entities
5313 .iter()
5314 .find(|e| e.id == id)
5315 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
5316 .unwrap_or(f32::MAX)
5317 };
5318 dist(*a_id)
5319 .partial_cmp(&dist(*b_id))
5320 .unwrap_or(std::cmp::Ordering::Equal)
5321 .then_with(|| a_label.cmp(b_label))
5322 .then_with(|| a_id.cmp(b_id))
5323 });
5324 out
5325 }
5326
5327 pub fn refresh_combat_target_label(&mut self) {
5328 let Some(id) = self.combat_target else {
5329 return;
5330 };
5331 if let Some((_, label)) = self
5332 .combat_candidates()
5333 .into_iter()
5334 .find(|(eid, _)| *eid == id)
5335 {
5336 self.combat_target_label = Some(label);
5337 } else if let Some(label) = self
5338 .entities
5339 .iter()
5340 .find(|e| e.id == id)
5341 .map(|e| e.label.clone())
5342 {
5343 self.combat_target_label = Some(label);
5344 }
5345 }
5346
5347 pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
5348 self.quest_log
5349 .iter()
5350 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
5351 .collect()
5352 }
5353
5354 pub fn has_worker_lodging(&self) -> bool {
5356 self.free_worker_lodging_slots() > 0
5357 }
5358
5359 pub fn free_worker_lodging_slots(&self) -> i64 {
5361 let slots: u32 = self
5362 .placed_containers
5363 .iter()
5364 .filter(|c| match (self.character_id, c.owner_character_id) {
5365 (Some(me), Some(owner)) => me == owner,
5366 (Some(_), None) => false,
5367 (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
5368 })
5369 .map(|c| c.worker_lodging_capacity.unwrap_or(0))
5370 .sum();
5371 let used = self.hired_workers.len() as u32;
5372 slots as i64 - used as i64
5373 }
5374
5375 pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
5377 let mut names: Vec<String> = self
5378 .hired_workers
5379 .iter()
5380 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
5381 .map(|w| w.label.clone())
5382 .collect();
5383 names.sort();
5384 names
5385 }
5386
5387 pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
5389 let is_lodging = self
5390 .placed_containers
5391 .iter()
5392 .find(|c| c.id == container_id)
5393 .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
5394 if !is_lodging {
5395 return None;
5396 }
5397 let names = self.lodging_occupant_labels(container_id);
5398 Some(if names.is_empty() {
5399 "vacant".into()
5400 } else {
5401 names.join(", ")
5402 })
5403 }
5404
5405 pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
5406 self.quest_log
5407 .iter()
5408 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
5409 .or_else(|| {
5410 self.quest_log
5411 .iter()
5412 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
5413 })
5414 }
5415
5416 pub fn nearby_lockable_door(&self) -> bool {
5418 let (px, py) = self.player_position();
5419 self.doors
5420 .iter()
5421 .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M)
5422 }
5423
5424 pub fn nearby_open_player_door(&self) -> bool {
5426 if self.effective_inside_building().is_some() {
5427 return false;
5428 }
5429 let (px, py) = self.player_position();
5430 self.doors.iter().any(|d| {
5431 if !d.open || d.locked {
5432 return false;
5433 }
5434 let player_house = self
5435 .buildings
5436 .iter()
5437 .find(|b| b.id == d.building_id)
5438 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5439 player_house && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M
5440 })
5441 }
5442
5443 pub fn nearby_player_exit_door(&self) -> bool {
5445 let Some(bid) = self.effective_inside_building() else {
5446 return false;
5447 };
5448 let (px, py) = self.player_position();
5449 self.doors.iter().any(|d| {
5450 if d.building_id != bid || d.portal.is_none() {
5451 return false;
5452 }
5453 let player_house = self
5454 .buildings
5455 .iter()
5456 .find(|b| b.id == d.building_id)
5457 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5458 player_house && (d.x - px).hypot(d.y - py) <= 1.5
5459 })
5460 }
5461
5462 pub fn nearest_interact_target(&self) -> Option<String> {
5464 let (px, py) = self.player_position();
5465 let inside = self.effective_inside_building();
5466
5467 #[derive(Clone, Copy, PartialEq, Eq)]
5468 enum Kind {
5469 Player,
5470 Npc,
5471 HiredWorker,
5472 QuestBoard,
5473 ExitDoor,
5474 EnterDoor,
5475 Well,
5476 Water,
5477 }
5478
5479 fn kind_priority(kind: Kind) -> u8 {
5480 match kind {
5481 Kind::Player => 0,
5482 Kind::Npc => 0,
5483 Kind::HiredWorker => 0,
5484 Kind::QuestBoard => 1,
5485 Kind::ExitDoor => 2,
5486 Kind::EnterDoor => 3,
5487 Kind::Well => 4,
5488 Kind::Water => 5,
5489 }
5490 }
5491
5492 let mut best: Option<(f32, Kind, String)> = None;
5493
5494 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
5495 if dist > max {
5496 return;
5497 }
5498 let replace = match best {
5499 None => true,
5500 Some((bd, _bk, _)) if dist < bd - 0.05 => true,
5501 Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
5502 kind_priority(kind) < kind_priority(bk)
5503 }
5504 _ => false,
5505 };
5506 if replace {
5507 best = Some((dist, kind, id));
5508 }
5509 };
5510
5511 for npc in &self.npcs {
5512 consider(
5513 distance(px, py, npc.x, npc.y),
5514 INTERACTION_RADIUS_M,
5515 Kind::Npc,
5516 npc.id.clone(),
5517 );
5518 }
5519
5520 for worker in &self.hired_workers {
5521 consider(
5522 distance(px, py, worker.x, worker.y),
5523 INTERACTION_RADIUS_M,
5524 Kind::HiredWorker,
5525 worker.instance_id.clone(),
5526 );
5527 }
5528
5529 for entity in &self.entities {
5530 if entity.id == self.entity_id
5531 || entity.vitals.is_none()
5532 || entity.label.trim().is_empty()
5533 {
5534 continue;
5535 }
5536 if self.hired_workers.iter().any(|w| w.entity_id == entity.id) {
5538 continue;
5539 }
5540 consider(
5541 distance(
5542 px,
5543 py,
5544 entity.transform.position.x,
5545 entity.transform.position.y,
5546 ),
5547 INTERACTION_RADIUS_M,
5548 Kind::Player,
5549 entity.id.to_string(),
5550 );
5551 }
5552
5553 for door in &self.doors {
5554 if let Some(ref bid) = inside {
5555 if door.building_id != *bid {
5556 continue;
5557 }
5558 let is_exit = door.portal.is_some();
5559 let max = if is_exit {
5560 INTERACTION_RADIUS_M
5561 } else {
5562 DOOR_INTERACTION_RADIUS_M
5563 };
5564 let kind = if is_exit {
5565 Kind::ExitDoor
5566 } else {
5567 Kind::EnterDoor
5568 };
5569 consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
5570 continue;
5571 }
5572 consider(
5573 distance(px, py, door.x, door.y),
5574 DOOR_INTERACTION_RADIUS_M,
5575 Kind::EnterDoor,
5576 door.id.clone(),
5577 );
5578 }
5579
5580 if inside.is_none() {
5581 for inter in &self.interactables {
5582 if inter.kind == "quest_board" {
5583 consider(
5584 distance(px, py, inter.x, inter.y),
5585 QUEST_BOARD_INTERACTION_RADIUS_M,
5586 Kind::QuestBoard,
5587 inter.id.clone(),
5588 );
5589 }
5590 }
5591 for building in &self.buildings {
5592 if !building.tags.iter().any(|t| t == "well") {
5593 continue;
5594 }
5595 consider(
5596 distance(px, py, building.x, building.y),
5597 INTERACTION_RADIUS_M,
5598 Kind::Well,
5599 building.id.clone(),
5600 );
5601 }
5602 if self.in_shallow_water() {
5603 consider(
5604 0.0,
5605 INTERACTION_RADIUS_M,
5606 Kind::Water,
5607 "water_source".into(),
5608 );
5609 }
5610 }
5611
5612 best.map(|(_, _, id)| id)
5613 }
5614
5615 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
5617 if self.effective_inside_building().is_some() {
5618 return None;
5619 }
5620 let (px, py) = self.player_position();
5621 self.interactables
5622 .iter()
5623 .filter(|i| i.kind == "quest_board")
5624 .map(|i| {
5625 let label = if i.label.is_empty() {
5626 "Quest board".to_string()
5627 } else {
5628 i.label.clone()
5629 };
5630 (label, distance(px, py, i.x, i.y))
5631 })
5632 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
5633 }
5634
5635 pub fn template_display_name(&self, template_id: &str) -> String {
5637 self.inventory_hints
5638 .get(template_id)
5639 .map(|h| h.display_name.clone())
5640 .filter(|n| !n.is_empty())
5641 .unwrap_or_else(|| humanize_template_id(template_id))
5642 }
5643
5644 pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
5646 if !display_name.is_empty() {
5647 display_name.to_string()
5648 } else {
5649 self.template_display_name(template_id)
5650 }
5651 }
5652
5653 pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
5654 self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
5655 }
5656
5657 pub fn blueprint_ingredient_label(
5658 &self,
5659 input: &flatland_protocol::BlueprintIngredientView,
5660 ) -> String {
5661 self.blueprint_item_label(&input.template_id, &input.display_name)
5662 }
5663
5664 pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
5665 self.blueprint_item_label(&tool.item, &tool.display_name)
5666 }
5667
5668 pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
5670 use crate::worker_route_editor::{
5671 node_candidates, node_candidates_stable, route_editor_lodging_anchor,
5672 };
5673 let lodging = self
5674 .worker_route_editor
5675 .as_ref()
5676 .and_then(|ed| ed.lodging_container_id.as_deref());
5677 match route_editor_lodging_anchor(lodging, &self.placed_containers) {
5678 Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
5679 None => node_candidates_stable(&self.resource_nodes),
5680 }
5681 }
5682
5683 pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
5684 if dist_m.is_nan() {
5685 return "—".into();
5686 }
5687 let from_bed = self
5688 .worker_route_editor
5689 .as_ref()
5690 .and_then(|ed| ed.lodging_container_id.as_deref())
5691 .and_then(|id| {
5692 self.placed_containers
5693 .iter()
5694 .find(|c| c.id == id)
5695 .map(|c| c.display_name.clone())
5696 });
5697 match from_bed {
5698 Some(bed) => format!("{dist_m:.0}m from {bed}"),
5699 None => format!("{dist_m:.0}m"),
5700 }
5701 }
5702
5703 pub fn placed_container_public_label(
5705 &self,
5706 c: &flatland_protocol::PlacedContainerView,
5707 ) -> String {
5708 let is_owner = match (self.character_id, c.owner_character_id) {
5709 (Some(me), Some(owner)) => me == owner,
5710 _ => false,
5711 };
5712 if is_owner {
5713 c.display_name.clone()
5714 } else {
5715 self.template_display_name(&c.template_id)
5716 }
5717 }
5718
5719 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
5721 let mut out = Vec::new();
5722 for stack in &self.inventory_stacks {
5723 if stack.template_id == KEY_TEMPLATE {
5724 out.push(KeychainEntry {
5725 stack: stack.clone(),
5726 stowed: false,
5727 });
5728 }
5729 }
5730 for stack in &self.keychain_stacks {
5731 if stack.template_id == KEY_TEMPLATE {
5732 out.push(KeychainEntry {
5733 stack: stack.clone(),
5734 stowed: true,
5735 });
5736 }
5737 }
5738 out
5739 }
5740
5741 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
5743 if stack.template_id != KEY_TEMPLATE {
5744 return None;
5745 }
5746 if let Some(name) = stack
5747 .props
5748 .get(PROP_OPENS_CONTAINER_NAME)
5749 .filter(|n| !n.is_empty())
5750 {
5751 return Some(name.clone());
5752 }
5753 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
5754 self.container_name_for_lock_id(opens)
5755 }
5756
5757 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
5759 if stack.template_id == KEY_TEMPLATE {
5760 self.template_display_name(KEY_TEMPLATE)
5761 } else {
5762 stack
5763 .display_name
5764 .clone()
5765 .unwrap_or_else(|| stack.template_id.clone())
5766 }
5767 }
5768
5769 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
5771 if stack.template_id != KEY_TEMPLATE {
5772 return String::new();
5773 }
5774 match self.key_pair_chest_label(stack) {
5775 Some(chest) if self.key_drop_blocked(stack) => {
5776 format!(" [key for {chest} — can't drop while locked]")
5777 }
5778 Some(chest) => format!(" [key for {chest}]"),
5779 None => " [key — unpaired]".into(),
5780 }
5781 }
5782
5783 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
5785 for c in &self.placed_containers {
5786 if c.lock_id.as_deref() == Some(lock) {
5787 return Some(c.display_name.clone());
5788 }
5789 }
5790 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
5791 self.worn
5792 .values()
5793 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
5794 })
5795 }
5796
5797 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
5799 if stack.template_id != KEY_TEMPLATE {
5800 return false;
5801 }
5802 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
5803 return false;
5804 };
5805 for c in &self.placed_containers {
5806 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
5807 return true;
5808 }
5809 }
5810 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
5811 return true;
5812 }
5813 self.worn
5814 .values()
5815 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
5816 }
5817
5818 pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
5820 stack.template_id == PROPERTY_DEED_TEMPLATE
5821 }
5822
5823 pub fn is_property_deed_template(template_id: &str) -> bool {
5824 template_id == PROPERTY_DEED_TEMPLATE
5825 }
5826
5827 pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
5828 stack
5829 .props
5830 .get("plot_id")
5831 .and_then(|s| uuid::Uuid::parse_str(s).ok())
5832 }
5833
5834 pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
5836 let (px, py) = self.player_position();
5837 let (cx, cy) = self.farm_plot_cell_under_player()?;
5838 let tx = cx as f32 + 0.5;
5839 let ty = cy as f32 + 0.5;
5840 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5841 return None;
5842 }
5843 let kind = self.terrain_at(tx, ty).or_else(|| self.terrain_at(px, py));
5844 if kind == Some(TerrainKindView::Tilled) {
5845 return None;
5846 }
5847 if matches!(
5848 kind,
5849 Some(TerrainKindView::ShallowWater)
5850 | Some(TerrainKindView::DeepWater)
5851 | Some(TerrainKindView::Rock)
5852 ) {
5853 return None;
5854 }
5855 Some((tx, ty))
5856 }
5857
5858 fn container_name_in_stacks(
5859 stacks: &[flatland_protocol::ItemStack],
5860 lock: &str,
5861 ) -> Option<String> {
5862 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
5863 for s in stacks {
5864 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
5865 return Some(GameState::stack_container_label(s));
5866 }
5867 if let Some(name) = walk(&s.contents, lock) {
5868 return Some(name);
5869 }
5870 }
5871 None
5872 }
5873 walk(stacks, lock)
5874 }
5875
5876 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
5877 stack
5878 .props
5879 .get(PROP_CUSTOM_NAME)
5880 .cloned()
5881 .or_else(|| stack.display_name.clone())
5882 .unwrap_or_else(|| stack.template_id.clone())
5883 }
5884
5885 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5886 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5887 for s in stacks {
5888 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
5889 return true;
5890 }
5891 if walk(&s.contents, lock) {
5892 return true;
5893 }
5894 }
5895 false
5896 }
5897 walk(stacks, lock)
5898 }
5899
5900 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
5901 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
5902 return Some(stack.clone());
5903 }
5904 for worn in self.worn.values() {
5905 if worn.item_instance_id == Some(instance_id) {
5906 return Some(worn.clone());
5907 }
5908 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
5909 return Some(stack.clone());
5910 }
5911 }
5912 None
5913 }
5914
5915 pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
5917 self.property_zones
5918 .iter()
5919 .enumerate()
5920 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5921 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5922 .map(|(_, z)| z)
5923 }
5924
5925 pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
5927 self.tax_zones
5928 .iter()
5929 .enumerate()
5930 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5931 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5932 .map(|(_, z)| z)
5933 }
5934
5935 pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
5937 let mut max_bps = 0u32;
5938 let mut y = y0 + 0.5;
5939 while y < y1 {
5940 let mut x = x0 + 0.5;
5941 while x < x1 {
5942 if let Some(tz) = self.tax_zone_at(x, y) {
5943 max_bps = max_bps.max(tz.rate_bps);
5944 }
5945 x += 1.0;
5946 }
5947 y += 1.0;
5948 }
5949 max_bps
5950 }
5951
5952 pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5954 let mode = self.claim_mode.as_ref()?;
5955 let w = mode.width_m.max(1) as f32;
5956 let h = mode.height_m.max(1) as f32;
5957 Some((
5958 mode.anchor_x,
5959 mode.anchor_y,
5960 mode.anchor_x + w,
5961 mode.anchor_y + h,
5962 ))
5963 }
5964
5965 pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5967 let mode = self.relocate_mode.as_ref()?;
5968 let x0 = mode.cursor_x.floor();
5969 let y0 = mode.cursor_y.floor();
5970 Some((x0, y0, x0 + 1.0, y0 + 1.0))
5971 }
5972
5973 pub fn claim_quote(&self) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
5976 let mode = self.claim_mode.as_ref()?;
5977 let zone = self.property_zones.iter().find(|z| z.id == mode.zone_id)?;
5978 let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
5979 let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
5980 let zone_area = zone_view_area_m2(zone).max(1.0);
5981 let area_frac = (area / zone_area).clamp(0.0, 1.0);
5982 let weight = self
5983 .property_plot_settings
5984 .as_ref()
5985 .map(|s| s.tax_premium_weight)
5986 .unwrap_or(0.5)
5987 .max(0.0);
5988 let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
5989 let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
5990 let purchase = ((zone.crown_price_copper as f64) * (area_frac as f64) * (premium as f64))
5991 .ceil()
5992 .max(0.0) as u64;
5993 let upkeep = if zone.upkeep_copper_per_day == 0 {
5994 0
5995 } else {
5996 ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
5997 .ceil()
5998 .max(1.0) as u64
5999 };
6000 let copper = crate::currency::copper_from_counts(&self.inventory);
6001 let can_afford = copper >= purchase;
6002 let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
6003 Some((purchase, upkeep, area, premium, can_afford, valid, reason))
6004 }
6005
6006 fn validate_claim_footprint(
6007 &self,
6008 zone: &flatland_protocol::PropertyZoneView,
6009 x0: f32,
6010 y0: f32,
6011 x1: f32,
6012 y1: f32,
6013 area: f32,
6014 ) -> (bool, String) {
6015 let min_area = self
6016 .property_plot_settings
6017 .as_ref()
6018 .map(|s| s.min_plot_area_m2)
6019 .unwrap_or(4.0);
6020 if area + f32::EPSILON < min_area {
6021 return (false, "plot too small".into());
6022 }
6023 if zone.max_area_m2.is_some_and(|m| area > m) {
6024 return (false, "plot exceeds max area".into());
6025 }
6026 if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
6027 return (false, "plot must lie inside the property zone".into());
6028 }
6029 if self
6030 .property_plots
6031 .iter()
6032 .any(|p| rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1))
6033 {
6034 return (false, "plot overlaps an existing claim".into());
6035 }
6036 (true, String::new())
6037 }
6038
6039 pub fn free_property_zone_under_player(&self) -> Option<&flatland_protocol::PropertyZoneView> {
6041 let (px, py) = self.player_position();
6042 let zone = self.property_zone_at(px, py)?;
6043 if self.property_plots.iter().any(|p| point_in_plot(px, py, p)) {
6044 return None;
6045 }
6046 Some(zone)
6047 }
6048
6049 pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
6051 let (px, py) = self.player_position();
6052 self.property_plots
6053 .iter()
6054 .find(|p| p.is_mine && point_in_plot(px, py, p))
6055 }
6056
6057 pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
6059 let (px, py) = self.player_position();
6060 self.property_plots
6061 .iter()
6062 .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
6063 }
6064
6065 pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
6067 if self.farmable_plot_under_player().is_none() {
6068 return None;
6069 }
6070 let (px, py) = self.player_position();
6071 Some((px.floor() as i32, py.floor() as i32))
6072 }
6073
6074 fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
6075 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6076 self.resource_nodes.iter().any(|n| {
6077 let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
6078 ncx == cx && ncy == cy || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
6079 })
6080 }
6081
6082 fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
6083 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6084 let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
6085 || self
6086 .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
6087 .is_some_and(|z| z.kind == TerrainKindView::Tilled);
6088 if !tilled {
6089 return false;
6090 }
6091 !self.resource_node_occupies_farm_cell(cx, cy)
6092 }
6093
6094 pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
6096 let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
6097 return false;
6098 };
6099 self.free_tilled_plant_slot_at(cx, cy)
6100 }
6101
6102 pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
6104 let (px, py) = self.player_position();
6105 for dy in -2..=2 {
6106 for dx in -2..=2 {
6107 let cx = px.floor() as i32 + dx;
6108 let cy = py.floor() as i32 + dy;
6109 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6110 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6111 continue;
6112 }
6113 if self.free_tilled_plant_slot_at(cx, cy) {
6114 return true;
6115 }
6116 }
6117 }
6118 false
6119 }
6120
6121 fn stack_is_farm_seed(stack: &flatland_protocol::ItemStack) -> bool {
6122 stack.quantity > 0
6123 && (stack.props.contains_key("seed_for")
6124 || stack.template_id.ends_with("_seed")
6125 || stack.template_id == "potato_seed"
6126 || stack.template_id == "carrot_seed")
6127 }
6128
6129 pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
6131 let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
6132 fn walk(
6133 stacks: &[flatland_protocol::ItemStack],
6134 counts: &mut std::collections::HashMap<String, u32>,
6135 ) {
6136 for s in stacks {
6137 if GameState::stack_is_farm_seed(s) {
6138 *counts.entry(s.template_id.clone()).or_default() += s.quantity;
6139 }
6140 walk(&s.contents, counts);
6141 }
6142 }
6143 walk(&self.inventory_stacks, &mut counts);
6144 for worn in self.worn.values() {
6145 walk(std::slice::from_ref(worn), &mut counts);
6146 }
6147 let mut out: Vec<_> = counts
6148 .into_iter()
6149 .map(|(template_id, quantity)| {
6150 let label = self
6151 .inventory_hints
6152 .get(&template_id)
6153 .map(|h| h.display_name.clone())
6154 .filter(|n| !n.trim().is_empty())
6155 .unwrap_or_else(|| humanize_template_id(&template_id));
6156 (template_id, quantity, label)
6157 })
6158 .collect();
6159 out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
6160 out
6161 }
6162
6163 pub fn first_farm_seed_template(&self) -> Option<String> {
6165 self.farm_seed_entries()
6166 .into_iter()
6167 .next()
6168 .map(|(id, _, _)| id)
6169 }
6170
6171 pub fn clamp_plant_menu(&mut self) {
6172 let n = self.farm_seed_entries().len();
6173 if n == 0 {
6174 self.plant_menu_index = 0;
6175 self.plant_quantity = 1;
6176 return;
6177 }
6178 self.plant_menu_index = self.plant_menu_index.min(n - 1);
6179 let max_qty = self
6180 .farm_seed_entries()
6181 .get(self.plant_menu_index)
6182 .map(|(_, q, _)| *q)
6183 .unwrap_or(1)
6184 .max(1);
6185 self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
6186 }
6187
6188 pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
6189 let entries = self.farm_seed_entries();
6190 let (id, max, label) = entries.get(self.plant_menu_index)?;
6191 let qty = self.plant_quantity.min(*max).max(1);
6192 Some((id.clone(), qty, label.clone()))
6193 }
6194
6195 pub fn location_context_lines(&self) -> Vec<ContextLine> {
6197 let (px, py) = self.player_position();
6198 let inside = self.effective_inside_building();
6199 let mut lines = Vec::new();
6200
6201 if let Some(kind) = self.terrain_at(px, py) {
6202 lines.push(ContextLine {
6203 on_top: true,
6204 text: format!("Terrain: {}", terrain_kind_label(kind)),
6205 });
6206 }
6207
6208 if let Some(id) = inside.as_ref() {
6209 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
6210 lines.push(ContextLine {
6211 on_top: true,
6212 text: format!("Inside: {}", b.label),
6213 });
6214 }
6215 }
6216
6217 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
6218
6219 for node in &self.resource_nodes {
6220 if node.id.starts_with("preview:") {
6221 continue;
6222 }
6223 let dist = distance(px, py, node.x, node.y);
6224 if dist > NEARBY_SCAN_M {
6225 continue;
6226 }
6227 let on_top = dist <= ON_TOP_RADIUS_M;
6228 let prefix = if on_top { "On" } else { "Near" };
6229 let name = resource_node_near_display_label(&node.label);
6230 let action = resource_node_near_action_suffix(node);
6231 nearby.push((
6232 dist,
6233 ContextLine {
6234 on_top,
6235 text: format!("{prefix}: {name} ({dist:.1}m){action}"),
6236 },
6237 ));
6238 }
6239
6240 for drop in &self.ground_drops {
6241 let dist = distance(px, py, drop.x, drop.y);
6242 if dist > INTERACTION_RADIUS_M {
6243 continue;
6244 }
6245 let on_top = dist <= ON_TOP_RADIUS_M;
6246 let name = self.template_display_name(&drop.template_id);
6247 let prefix = if on_top { "On" } else { "Near" };
6248 let qty = if drop.quantity > 1 {
6249 format!(" ×{}", drop.quantity)
6250 } else {
6251 String::new()
6252 };
6253 nearby.push((
6254 dist,
6255 ContextLine {
6256 on_top,
6257 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
6258 },
6259 ));
6260 }
6261
6262 for c in &self.placed_containers {
6263 if !self.placed_container_in_current_space(c) {
6264 continue;
6265 }
6266 let dist = distance(px, py, c.x, c.y);
6267 if dist > CONTAINER_RANGE_M {
6268 continue;
6269 }
6270 let on_top = dist <= ON_TOP_RADIUS_M;
6271 let name = self.placed_container_public_label(c);
6272 let lock = if c.locked { " [locked]" } else { "" };
6273 let prefix = if on_top { "On" } else { "Near" };
6274 nearby.push((
6275 dist,
6276 ContextLine {
6277 on_top,
6278 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
6279 },
6280 ));
6281 }
6282
6283 for npc in &self.npcs {
6284 let dist = distance(px, py, npc.x, npc.y);
6285 if dist > NEARBY_SCAN_M {
6286 continue;
6287 }
6288 let on_top = dist <= ON_TOP_RADIUS_M;
6289 let prefix = if on_top { "On" } else { "Near" };
6290 nearby.push((
6291 dist,
6292 ContextLine {
6293 on_top,
6294 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
6295 },
6296 ));
6297 }
6298
6299 for door in &self.doors {
6300 let dist = distance(px, py, door.x, door.y);
6301 if dist > DOOR_INTERACTION_RADIUS_M {
6302 continue;
6303 }
6304 let building = self
6305 .buildings
6306 .iter()
6307 .find(|b| b.id == door.building_id)
6308 .map(|b| b.label.as_str())
6309 .unwrap_or(door.building_id.as_str());
6310 let player_house = self
6311 .buildings
6312 .iter()
6313 .find(|b| b.id == door.building_id)
6314 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6315 let action = if inside.is_some() && door.portal.is_some() {
6316 if player_house {
6317 if door.locked {
6318 "locked — l unlock · Enter exit".to_string()
6319 } else if door.open {
6320 "close · Enter exit · l lock".to_string()
6321 } else {
6322 "open · Enter exit · l lock".to_string()
6323 }
6324 } else {
6325 "exit".to_string()
6326 }
6327 } else if player_house {
6328 if door.locked {
6329 "locked — l unlock".to_string()
6330 } else if door.open {
6331 "close · Enter go inside · l lock".to_string()
6332 } else {
6333 "open · l lock".to_string()
6334 }
6335 } else {
6336 "enter".to_string()
6337 };
6338 nearby.push((
6339 dist,
6340 ContextLine {
6341 on_top: dist <= ON_TOP_RADIUS_M,
6342 text: format!("{building} door ({dist:.1}m) — f {action}"),
6343 },
6344 ));
6345 }
6346
6347 if inside.is_none() {
6348 for inter in &self.interactables {
6349 if inter.kind != "quest_board" {
6350 continue;
6351 }
6352 let dist = distance(px, py, inter.x, inter.y);
6353 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
6354 continue;
6355 }
6356 let on_top = dist <= ON_TOP_RADIUS_M;
6357 let prefix = if on_top { "On" } else { "Near" };
6358 let label = if inter.label.is_empty() {
6359 "Quest board".to_string()
6360 } else {
6361 inter.label.clone()
6362 };
6363 nearby.push((
6364 dist,
6365 ContextLine {
6366 on_top,
6367 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
6368 },
6369 ));
6370 }
6371 }
6372
6373 if self.in_shallow_water() {
6374 let already = self
6375 .terrain_at(px, py)
6376 .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
6377 if !already {
6378 nearby.push((
6379 0.0,
6380 ContextLine {
6381 on_top: true,
6382 text: "Shallow water — f fill bottle".into(),
6383 },
6384 ));
6385 } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
6386 line.text.push_str(" — f fill bottle");
6387 }
6388 }
6389
6390 if self.claim_mode.is_some() {
6391 nearby.push((
6392 0.0,
6393 ContextLine {
6394 on_top: true,
6395 text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
6396 .into(),
6397 },
6398 ));
6399 } else if let Some(plot) = self.my_plot_under_player() {
6400 let name = plot_public_label(plot);
6401 let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
6402 format!("{name} — f again to sell to crown")
6403 } else {
6404 format!(
6405 "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
6406 )
6407 };
6408 nearby.push((
6409 0.0,
6410 ContextLine {
6411 on_top: true,
6412 text: prompt,
6413 },
6414 ));
6415 } else if let Some(plot) = self.farmable_plot_under_player() {
6416 let name = plot_public_label(plot);
6417 let disc = if plot.farm_public {
6418 plot.public_tax_discount_bps / 100
6419 } else {
6420 plot.farm_allow
6421 .iter()
6422 .find(|g| Some(g.character_id) == self.character_id)
6423 .map(|g| g.tax_discount_bps / 100)
6424 .unwrap_or(0)
6425 };
6426 nearby.push((
6427 0.0,
6428 ContextLine {
6429 on_top: true,
6430 text: format!(
6431 "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
6432 ),
6433 },
6434 ));
6435 } else if let Some(zone) = self.free_property_zone_under_player() {
6436 let label = zone
6437 .label
6438 .as_deref()
6439 .filter(|s| !s.trim().is_empty())
6440 .unwrap_or(zone.id.as_str());
6441 nearby.push((
6442 0.0,
6443 ContextLine {
6444 on_top: true,
6445 text: format!("Claimable land: {label} — k buy plot"),
6446 },
6447 ));
6448 }
6449
6450 for entity in &self.entities {
6451 if entity.id == self.entity_id {
6452 continue;
6453 }
6454 let dist = distance(
6455 px,
6456 py,
6457 entity.transform.position.x,
6458 entity.transform.position.y,
6459 );
6460 if dist > NEARBY_SCAN_M {
6461 continue;
6462 }
6463 let label = if entity.label.is_empty() {
6464 format!("entity {}", entity.id)
6465 } else {
6466 entity.label.clone()
6467 };
6468 nearby.push((
6469 dist,
6470 ContextLine {
6471 on_top: dist <= ON_TOP_RADIUS_M,
6472 text: format!("Near: {label} ({dist:.1}m)"),
6473 },
6474 ));
6475 }
6476
6477 nearby.sort_by(|a, b| {
6478 a.0.partial_cmp(&b.0)
6479 .unwrap_or(std::cmp::Ordering::Equal)
6480 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
6481 });
6482 lines.extend(nearby.into_iter().map(|(_, l)| l));
6483
6484 if lines.is_empty() {
6485 lines.push(ContextLine {
6486 on_top: false,
6487 text: "(nothing notable nearby)".into(),
6488 });
6489 }
6490
6491 lines
6492 }
6493}
6494
6495#[derive(Debug, Clone)]
6497pub struct ContextLine {
6498 pub on_top: bool,
6499 pub text: String,
6500}
6501
6502const ON_TOP_RADIUS_M: f32 = 0.65;
6503const NEARBY_SCAN_M: f32 = 5.0;
6504
6505pub fn resource_node_near_display_label(label: &str) -> String {
6507 label
6508 .strip_suffix(" (growing)")
6509 .unwrap_or(label)
6510 .to_string()
6511}
6512
6513fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
6514 let t = label.trim();
6515 if t.is_empty() || t == id {
6516 return true;
6517 }
6518 let lower = t.to_ascii_lowercase();
6519 if lower.contains("_copy") {
6520 return true;
6521 }
6522 false
6523}
6524
6525fn humanize_item_template_label(template: &str) -> String {
6526 let base = template.rsplit('/').next().unwrap_or(template).trim();
6527 if base.is_empty() {
6528 return "Resource".into();
6529 }
6530 let stripped = base
6531 .strip_prefix("crop-")
6532 .or_else(|| base.strip_prefix("crop_"))
6533 .unwrap_or(base);
6534 stripped
6535 .split(|c: char| c == '-' || c == '_')
6536 .filter(|p| !p.is_empty())
6537 .map(|p| {
6538 let mut chars = p.chars();
6539 match chars.next() {
6540 Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
6541 None => String::new(),
6542 }
6543 })
6544 .collect::<Vec<_>>()
6545 .join(" ")
6546}
6547
6548pub fn resource_node_id_suffix(id: &str) -> String {
6550 let chars: Vec<char> = id
6551 .chars()
6552 .rev()
6553 .filter(|c| c.is_ascii_alphanumeric())
6554 .take(4)
6555 .collect();
6556 chars.into_iter().rev().collect()
6557}
6558
6559pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
6561 resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
6562}
6563
6564pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
6565 let cleaned = resource_node_near_display_label(label);
6566 let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
6567 cleaned
6568 } else if !item_template.trim().is_empty() {
6569 humanize_item_template_label(item_template)
6570 } else {
6571 id.to_string()
6572 };
6573 let suffix = resource_node_id_suffix(id);
6574 if suffix.is_empty() {
6575 friendly
6576 } else {
6577 format!("{friendly} ({suffix})")
6578 }
6579}
6580
6581pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
6583 use flatland_protocol::ResourceNodeState;
6584 if node.harvest_off {
6585 return " (decorative)".to_string();
6586 }
6587 if let Some(p) = node.growth_progress {
6588 if p < 1.0 - f32::EPSILON {
6589 let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
6590 return format!(" (growing, {pct}%)");
6591 }
6592 return " — f harvest".to_string();
6593 }
6594 match node.state {
6595 ResourceNodeState::Available => " — f harvest".to_string(),
6596 ResourceNodeState::Harvesting => " (being harvested)".to_string(),
6597 ResourceNodeState::Cooldown => " (depleted)".to_string(),
6598 }
6599}
6600
6601fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
6602 use flatland_protocol::TerrainKindView;
6603 match kind {
6604 TerrainKindView::Grass => "Grass",
6605 TerrainKindView::Dirt => "Dirt",
6606 TerrainKindView::Tilled => "Tilled",
6607 TerrainKindView::Desert => "Desert",
6608 TerrainKindView::Hill => "Hills",
6609 TerrainKindView::Bog => "Bog",
6610 TerrainKindView::Beach => "Beach",
6611 TerrainKindView::ShallowWater => "Shallow water",
6612 TerrainKindView::DeepWater => "Deep water",
6613 TerrainKindView::Trail => "Trail",
6614 TerrainKindView::Road => "Road",
6615 TerrainKindView::Rock => "Rock",
6616 }
6617}
6618
6619fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
6620 crate::world_zones::zone_rects_contain(rects, x, y)
6621}
6622
6623fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
6624 zone.rects
6625 .iter()
6626 .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
6627 .sum()
6628}
6629
6630fn claim_rect_fully_inside_zone(
6631 zone: &flatland_protocol::PropertyZoneView,
6632 x0: f32,
6633 y0: f32,
6634 x1: f32,
6635 y1: f32,
6636) -> bool {
6637 let mut y = y0 + 0.5;
6638 while y < y1 {
6639 let mut x = x0 + 0.5;
6640 while x < x1 {
6641 if !zone_rects_contain(&zone.rects, x, y) {
6642 return false;
6643 }
6644 x += 1.0;
6645 }
6646 y += 1.0;
6647 }
6648 true
6649}
6650
6651fn rects_overlap_half_open(
6652 ax0: f32,
6653 ay0: f32,
6654 ax1: f32,
6655 ay1: f32,
6656 bx0: f32,
6657 by0: f32,
6658 bx1: f32,
6659 by1: f32,
6660) -> bool {
6661 ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
6662}
6663
6664fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
6665 x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
6666}
6667
6668fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
6669 plot_public_label(p)
6670}
6671
6672pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
6674 let zone = p
6675 .zone_label
6676 .as_deref()
6677 .filter(|s| !s.trim().is_empty())
6678 .unwrap_or_else(|| {
6679 if p.property_zone_id.is_empty() {
6680 "Homestead"
6681 } else {
6682 p.property_zone_id.as_str()
6683 }
6684 });
6685 let label = if p.label.trim().is_empty() {
6686 if p.plot_code.trim().is_empty() {
6687 p.plot_id.to_string()[..8.min(p.plot_id.to_string().len())].to_string()
6688 } else {
6689 p.plot_code.clone()
6690 }
6691 } else {
6692 p.label.clone()
6693 };
6694 match p
6695 .owner_label
6696 .as_deref()
6697 .map(str::trim)
6698 .filter(|s| !s.is_empty())
6699 {
6700 Some(owner) => format!("{owner} — {zone} — {label}"),
6701 None => format!("{zone} — {label}"),
6702 }
6703}
6704
6705fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
6707 let a = x0.min(x1).floor();
6708 let b = y0.min(y1).floor();
6709 let mut c = x0.max(x1).ceil();
6710 let mut d = y0.max(y1).ceil();
6711 if (c - a) < 1.0 {
6712 c = a + 1.0;
6713 }
6714 if (d - b) < 1.0 {
6715 d = b + 1.0;
6716 }
6717 (a, b, c, d)
6718}
6719
6720fn humanize_template_id(template_id: &str) -> String {
6721 if looks_like_template_uuid(template_id) {
6723 return "Unknown item".into();
6724 }
6725 template_id
6726 .split('_')
6727 .map(|word| {
6728 let mut chars = word.chars();
6729 match chars.next() {
6730 None => String::new(),
6731 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
6732 }
6733 })
6734 .collect::<Vec<_>>()
6735 .join(" ")
6736}
6737
6738fn looks_like_template_uuid(template_id: &str) -> bool {
6739 let bytes = template_id.as_bytes();
6740 if bytes.len() != 36 {
6741 return false;
6742 }
6743 let is_hex = |b: u8| b.is_ascii_hexdigit();
6744 let groups = [8usize, 4, 4, 4, 12];
6745 let mut i = 0;
6746 for (gi, &len) in groups.iter().enumerate() {
6747 if gi > 0 {
6748 if bytes.get(i) != Some(&b'-') {
6749 return false;
6750 }
6751 i += 1;
6752 }
6753 for _ in 0..len {
6754 if !bytes.get(i).copied().is_some_and(is_hex) {
6755 return false;
6756 }
6757 i += 1;
6758 }
6759 }
6760 true
6761}
6762
6763const HARVEST_RANGE_M: f32 = 1.5;
6765
6766pub struct GameClient<S: PlayConnection> {
6767 session: S,
6768 seq: Seq,
6769 pub state: GameState,
6770 last_move_forward: f32,
6771 last_move_strafe: f32,
6772}
6773
6774impl<S: PlayConnection> GameClient<S> {
6775 pub fn new(session: S) -> Self {
6776 let session_id = session.session_id();
6777 let entity_id = session.entity_id();
6778 let mut client = Self {
6779 session,
6780 seq: 0,
6781 last_move_forward: 0.0,
6782 last_move_strafe: 0.0,
6783 state: GameState {
6784 session_id,
6785 entity_id,
6786 character_id: None,
6787 tick: 0,
6788 chunk_rev: 0,
6789 content_rev: 0,
6790 publish_rev: 0,
6791 entities: Vec::new(),
6792 player: None,
6793 resource_nodes: Vec::new(),
6794 ground_drops: Vec::new(),
6795 placed_containers: Vec::new(),
6796 buildings: Vec::new(),
6797 doors: Vec::new(),
6798 interior_map: None,
6799 npcs: Vec::new(),
6800 blueprints: Vec::new(),
6801 building_materials: Vec::new(),
6802 world_x0: 0.0,
6803 world_y0: 0.0,
6804 world_width_m: 0.0,
6805 world_height_m: 0.0,
6806 terrain_zones: Vec::new(),
6807 z_platforms: Vec::new(),
6808 z_transitions: Vec::new(),
6809 z_bands_outdoor_backup: None,
6810 world_clock: flatland_protocol::WorldClock::default(),
6811 inventory: std::collections::HashMap::new(),
6812 inventory_hints: std::collections::HashMap::new(),
6813 logs: VecDeque::new(),
6814 intents_sent: 0,
6815 ticks_received: 0,
6816 connected: false,
6817 disconnect_reason: None,
6818 show_stats: false,
6819 hud_log_hidden: false,
6820 show_equip_menu: false,
6821 equip_menu_index: 0,
6822 show_craft_menu: false,
6823 show_plot_build_menu: false,
6824 plot_build_focus_wall: true,
6825 plot_build_wall_index: 0,
6826 plot_build_roof_index: 0,
6827 craft_menu_index: 0,
6828 craft_batch_quantity: 1,
6829 show_shop_menu: false,
6830 shop_catalog: None,
6831 bank_panel: None,
6832 bank_menu_index: 0,
6833 bank_ui_mode: BankUiMode::Menu,
6834 storage_panel: None,
6835 market_panel: None,
6836 market_menu_index: 0,
6837 market_filter: String::new(),
6838 market_filter_focused: false,
6839 market_category_filter: None,
6840 market_buy_confirm: None,
6841 market_ui_mode: MarketUiMode::Browse,
6842 storage_menu_index: 0,
6843 storage_ui_mode: StorageUiMode::Menu,
6844 shop_tab: ShopTab::default(),
6845 shop_menu_index: 0,
6846 shop_quantity: 1,
6847 shop_trade_log: VecDeque::new(),
6848 show_npc_verb_menu: false,
6849 npc_verb_target: None,
6850 npc_verb_index: 0,
6851 player_verbs: crate::social::PlayerVerbState::default(),
6852 social_chat: crate::social::SocialChatState::default(),
6853 trade_ui: crate::social::TradeUiState::default(),
6854 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
6855 show_npc_chat: false,
6856 npc_chat: None,
6857 show_inventory_menu: false,
6858 inventory_menu_index: 0,
6859 inventory_tab: InventoryTab::OnPerson,
6860 inventory_filter: String::new(),
6861 inventory_filter_focused: false,
6862 show_move_picker: false,
6863 show_rename_prompt: false,
6864 rename_plot_id: None,
6865 highlighted_plot_id: None,
6866 show_worker_rename: false,
6867 rename_buffer: String::new(),
6868 move_picker_index: 0,
6869 move_picker: None,
6870 show_grant_picker: false,
6871 grant_picker_index: 0,
6872 grant_picker: None,
6873 show_destroy_picker: false,
6874 destroy_confirm_pending: false,
6875 destroy_picker: None,
6876 combat_target: None,
6877 combat_target_label: None,
6878 ground_target: None,
6879 combat_fx: Vec::new(),
6880 ground_hazards: Vec::new(),
6881 property_zones: Vec::new(),
6882 tax_zones: Vec::new(),
6883 growth_zones: Vec::new(),
6884 biome_zones: Vec::new(),
6885 terrain_kind_nav: Vec::new(),
6886 property_plots: Vec::new(),
6887 property_plot_settings: None,
6888 claim_mode: None,
6889 relocate_mode: None,
6890 sell_plot_confirm: None,
6891 sell_plot_armed_at: None,
6892 show_plant_menu: false,
6893 plant_menu_index: 0,
6894 show_farm_access: false,
6895 farm_access_name_draft: String::new(),
6896 farm_access_discount_bps: 0,
6897 farm_access_index: 0,
6898 plant_quantity: 1,
6899 in_combat: false,
6900 auto_attack: true,
6901 combat_has_los: false,
6902 attack_cd_ticks: 0,
6903 gcd_ticks: 0,
6904 weapon_ability_id: "unarmed".into(),
6905 mainhand_template_id: None,
6906 mainhand_label: None,
6907 mainhand_instance_id: None,
6908 offhand_template_id: None,
6909 offhand_label: None,
6910 offhand_instance_id: None,
6911 mainhand_hand_slots: 1,
6912 defense: None,
6913 worn: BTreeMap::new(),
6914 carry_mass: 0.0,
6915 carry_mass_max: 0.0,
6916 encumbrance: flatland_protocol::EncumbranceState::Light,
6917 inventory_stacks: Vec::new(),
6918 keychain_stacks: Vec::new(),
6919 whisper_pouch_stacks: Vec::new(),
6920 combat_target_detail: None,
6921 statuses: Vec::new(),
6922 cast_progress: None,
6923 timed_channel: None,
6924 plot_build_offer: None,
6925 ability_cooldowns: Vec::new(),
6926 blocking_active: false,
6927 max_target_slots: 1,
6928 combat_slots: Vec::new(),
6929 rotation_presets: Vec::new(),
6930 known_abilities: Vec::new(),
6931 ability_meta: std::collections::HashMap::new(),
6932 ability_mastery: std::collections::HashMap::new(),
6933 hotbar: vec![None; 9],
6934 max_abilities_per_rotation: 0,
6935 show_loadout_menu: false,
6936 show_keychain_menu: false,
6937 keychain_menu_index: 0,
6938 show_rotation_editor: false,
6939 loadout_menu_index: 0,
6940 loadout_hotbar_slot: 1,
6941 loadout_ability_index: 0,
6942 loadout_focus_presets: false,
6943 rotation_editor: RotationEditorState::default(),
6944 harvest_in_progress: false,
6945 harvest_started_at: None,
6946 pending_craft_ack: None,
6947 pending_worker_job_ack: None,
6948 attending_worker_instance_id: None,
6949 quest_log: Vec::new(),
6950 interactables: Vec::new(),
6951 ledger: None,
6952 career: None,
6953 character_sheet_tab: CharacterSheetTab::Character,
6954 ledger_period: LedgerPeriod::Day,
6955 show_quest_offer: false,
6956 pending_quest_offer: None,
6957 show_quest_menu: false,
6958 quest_menu_index: 0,
6959 quest_withdraw_confirm: false,
6960 hired_workers: Vec::new(),
6961 show_workers_menu: false,
6962 workers_menu_index: 0,
6963 worker_dismiss_confirmation: None,
6964 workers_menu_compact: false,
6965 worker_step_display: BTreeMap::new(),
6966 worker_error_display: BTreeMap::new(),
6967 worker_health_ring_until: BTreeMap::new(),
6968 pending_worker_hire_since: None,
6969 show_worker_give_picker: false,
6970 worker_give_picker_index: 0,
6971 worker_give_picker: None,
6972 show_worker_give_target_picker: false,
6973 worker_give_target_picker_index: 0,
6974 worker_give_target_picker: None,
6975 show_worker_take_picker: false,
6976 worker_take_picker_index: 0,
6977 worker_take_picker: None,
6978 show_worker_teach_picker: false,
6979 worker_teach_picker_index: 0,
6980 worker_teach_picker: None,
6981 worker_route_editor: None,
6982 progression_curve: None,
6983 },
6984 };
6985 client.state.apply_client_ui_prefs();
6986 client
6987 }
6988
6989 pub fn entity_id(&self) -> EntityId {
6990 self.state.entity_id
6991 }
6992
6993 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
6994 if self.state.connected {
6995 return Ok(());
6996 }
6997
6998 loop {
6999 match self.session.next_event().await {
7000 Some(SessionEvent::Welcome {
7001 session_id,
7002 entity_id,
7003 snapshot,
7004 }) => {
7005 self.state
7006 .restore_from_welcome(session_id, entity_id, &snapshot);
7007 self.state.apply_client_ui_prefs();
7008 self.state.push_log(format!(
7009 "Connected — session {session_id}, entity {entity_id}"
7010 ));
7011 return Ok(());
7012 }
7013 Some(SessionEvent::Disconnected { .. }) => {
7014 anyhow::bail!("disconnected before welcome");
7015 }
7016 Some(_) => continue,
7017 None => anyhow::bail!("session closed before welcome"),
7018 }
7019 }
7020 }
7021
7022 pub fn drain_events(&mut self) {
7024 while let Some(event) = self.session.try_next_event() {
7025 if self.handle_event_sync(event).is_err() {
7026 break;
7027 }
7028 }
7029 }
7030
7031 pub async fn next_event(&mut self) -> Option<SessionEvent> {
7033 self.session.next_event().await
7034 }
7035
7036 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
7037 self.handle_event_sync(event)
7038 }
7039
7040 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
7041 match event {
7042 SessionEvent::Welcome {
7043 session_id,
7044 entity_id,
7045 snapshot,
7046 } => {
7047 let resumed = self.state.connected;
7048 self.state
7049 .restore_from_welcome(session_id, entity_id, &snapshot);
7050 if resumed {
7051 self.state.push_log(format!(
7052 "Session restored — session {session_id}, entity {entity_id}"
7053 ));
7054 }
7055 }
7056 SessionEvent::ContentUpdated { snapshot } => {
7057 self.state
7058 .apply_snapshot_fields(&snapshot, self.state.entity_id);
7059 self.state.push_log(format!(
7060 "World updated (content rev {})",
7061 snapshot.content_rev
7062 ));
7063 }
7064 SessionEvent::QuestCatalogUpdated(update) => {
7065 self.state.push_log(format!(
7066 "Quest board updated (revision {}, {} new, {} retired)",
7067 update.revision,
7068 update.accepted.len(),
7069 update.retired.len()
7070 ));
7071 }
7072 SessionEvent::Tick(delta) => {
7073 self.state.apply_tick_fields(&delta, self.state.entity_id);
7074 self.state.ticks_received += 1;
7075 }
7076 SessionEvent::IntentAck {
7077 entity_id,
7078 seq,
7079 tick,
7080 } => {
7081 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
7082 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
7083 if *craft_seq == seq {
7084 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
7085 if batches > 1 {
7086 self.state.push_log(format!("Crafting {label} ×{batches}…"));
7087 } else {
7088 self.state.push_log(format!("Crafting {label}…"));
7089 }
7090 }
7091 }
7092 if self
7093 .state
7094 .pending_worker_job_ack
7095 .as_ref()
7096 .is_some_and(|p| p.seq == seq)
7097 {
7098 let pending = self.state.pending_worker_job_ack.take().unwrap();
7099 if pending.idle {
7100 self.state.push_log(format!(
7101 "Route cleared for {} — worker idle",
7102 pending.worker_label
7103 ));
7104 } else {
7105 self.state.push_log(format!(
7106 "Route saved for {} — {} stop(s), job loop active",
7107 pending.worker_label, pending.stop_count
7108 ));
7109 }
7110 if self
7111 .state
7112 .worker_route_editor
7113 .as_ref()
7114 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
7115 {
7116 self.close_worker_route_editor();
7117 }
7118 }
7119 }
7120 SessionEvent::Chat(msg) => {
7121 let label = match msg.channel {
7122 flatland_protocol::ChatChannel::Nearby => "nearby",
7123 flatland_protocol::ChatChannel::Direct => "speak",
7124 flatland_protocol::ChatChannel::Whisper => "whisper",
7125 flatland_protocol::ChatChannel::WhisperStone => "stone",
7126 };
7127 let clarity = match msg.clarity {
7128 flatland_protocol::ChatClarity::Clear => "",
7129 flatland_protocol::ChatClarity::Partial => "~",
7130 flatland_protocol::ChatClarity::Heavy => "…",
7131 };
7132 self.state.push_log(format!(
7133 "[{label}{clarity}] {}: {}",
7134 msg.from_name, msg.text
7135 ));
7136 let now_ms = std::time::SystemTime::now()
7137 .duration_since(std::time::UNIX_EPOCH)
7138 .map(|d| d.as_millis() as u64)
7139 .unwrap_or(0);
7140 self.state
7141 .social_chat
7142 .note_speech(&msg, self.state.entity_id, now_ms);
7143 self.state
7144 .social_chat
7145 .push(crate::social::ChatLogEntry::from_message(
7146 msg,
7147 self.state.entity_id,
7148 ));
7149 }
7150 SessionEvent::TradeOpened(panel) => {
7151 self.state.social_chat.pending_trade = None;
7152 let peer = panel.peer_name.clone();
7153 self.state.trade_ui.open(panel);
7154 self.state.social_chat.push_system(format!(
7155 "Trade open with {peer} — p present · r ready · Esc cancel"
7156 ));
7157 self.state
7158 .social_chat
7159 .push_cue(crate::social::AudioCue::TradeOpened);
7160 }
7161 SessionEvent::TradeClosed { reason } => {
7162 self.state.push_log(reason.clone());
7163 self.state.social_chat.push_system(reason);
7164 self.state.trade_ui.close();
7165 }
7166 SessionEvent::HarvestResult(result) => {
7167 self.state.clear_harvest_state();
7168 crate::harvest_trace!(
7169 entity_id = self.state.entity_id,
7170 node_id = %result.node_id,
7171 template = %result.item_template,
7172 quantity = result.quantity,
7173 client_tick = self.state.tick,
7174 "client applied harvest result"
7175 );
7176 let msg = if result.quantity == 0 {
7177 format!(
7178 "Harvested {} x0 — nothing dropped (loot table rolled empty)",
7179 result.item_template
7180 )
7181 } else {
7182 format!(
7183 "Harvested {} x{} (on the ground — press P to pick up)",
7184 result.item_template, result.quantity
7185 )
7186 };
7187 self.state.push_log(msg);
7188 }
7189 SessionEvent::CraftResult(result) => {
7190 for stack in &result.consumed {
7191 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
7192 *qty = qty.saturating_sub(stack.quantity);
7193 if *qty == 0 {
7194 self.state.inventory.remove(&stack.template_id);
7195 }
7196 }
7197 }
7198 for stack in &result.outputs {
7199 *self
7200 .state
7201 .inventory
7202 .entry(stack.template_id.clone())
7203 .or_insert(0) += stack.quantity;
7204 }
7205 if let Some(output) = result.outputs.first() {
7206 if result.batch_total > 1 {
7207 self.state.push_log(format!(
7208 "Crafted {} x{} ({}/{})",
7209 output.template_id,
7210 output.quantity,
7211 result.batch_index,
7212 result.batch_total
7213 ));
7214 } else {
7215 self.state.push_log(format!(
7216 "Crafted {} x{}",
7217 output.template_id, output.quantity
7218 ));
7219 }
7220 } else {
7221 self.state
7222 .push_log(format!("Craft finished: {}", result.blueprint_id));
7223 }
7224 }
7225 SessionEvent::Death(notice) => {
7226 self.state.clear_harvest_state();
7227 self.state.push_log(notice.message.clone());
7228 self.state.push_log(format!(
7229 "Respawned at ({:.1}, {:.1})",
7230 notice.respawn_x, notice.respawn_y
7231 ));
7232 }
7233 SessionEvent::Interaction(notice) => {
7234 if notice.message.starts_with("Harvest failed:") {
7235 self.state.clear_harvest_state();
7236 }
7237 if notice.message.starts_with("Can't do that:") {
7238 self.state.pending_worker_hire_since = None;
7239 self.state.pending_craft_ack = None;
7240 if let Some(pending) = self.state.pending_worker_job_ack.take() {
7241 if let Some(w) = self
7242 .state
7243 .hired_workers
7244 .iter_mut()
7245 .find(|w| w.instance_id == pending.worker_instance_id)
7246 {
7247 w.route = pending.prev_route;
7248 w.mode = pending.prev_mode;
7249 w.step_label = pending.prev_step_label;
7250 w.last_error = pending.prev_last_error;
7251 }
7252 let reason = notice
7253 .message
7254 .strip_prefix("Can't do that:")
7255 .unwrap_or(¬ice.message)
7256 .trim();
7257 self.state.push_log(format!(
7258 "Route save failed for {}: {reason}",
7259 pending.worker_label
7260 ));
7261 }
7262 let reason = notice
7263 .message
7264 .strip_prefix("Can't do that:")
7265 .unwrap_or(¬ice.message)
7266 .trim();
7267 if reason.contains("already tilled") {
7268 if let Some(plot) = self.state.my_plot_under_player() {
7269 self.state.sell_plot_confirm = Some(plot.plot_id);
7270 self.state.sell_plot_armed_at = Some(Instant::now());
7271 }
7272 }
7273 }
7274 if notice.message.starts_with("Cast failed:") {
7275 self.state.cast_progress = None;
7276 }
7277 if notice.message.contains("slain the") {
7278 self.state.combat_target = None;
7279 self.state.combat_target_label = None;
7280 }
7281 if notice.message.contains("wants to trade") {
7283 if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
7284 let from_name = notice
7285 .message
7286 .split(" wants to trade")
7287 .next()
7288 .unwrap_or("Player")
7289 .to_string();
7290 self.state.social_chat.pending_trade =
7291 Some(crate::social::PendingTradeRequest {
7292 from_entity,
7293 from_name: from_name.clone(),
7294 });
7295 self.state.social_chat.push_system(format!(
7296 "{from_name} wants to trade — [Y] accept · [N] decline"
7297 ));
7298 self.state
7299 .social_chat
7300 .push_cue(crate::social::AudioCue::TradeOffer);
7301 }
7302 }
7303 if notice.message.starts_with("trade request declined") {
7304 self.state.social_chat.push_system(notice.message.clone());
7305 self.state
7306 .social_chat
7307 .push_cue(crate::social::AudioCue::TradeDeclined);
7308 }
7309 self.state.apply_interaction_notice(¬ice);
7310 self.state.push_log(notice.message.clone());
7311 }
7312 SessionEvent::ShopOpened(catalog) => {
7313 self.state.apply_shop_catalog(catalog);
7314 }
7315 SessionEvent::BankOpened(panel) => {
7316 self.state.apply_bank_panel(panel);
7317 }
7318 SessionEvent::StorageOpened(panel) => {
7319 self.state.apply_storage_panel(panel);
7320 }
7321 SessionEvent::MarketOpened(panel) => {
7322 self.state.apply_market_panel(panel);
7323 }
7324 SessionEvent::NpcTalkOpened(opened) => {
7325 self.state.show_npc_verb_menu = false;
7326 if self.state.npc_verb_target.is_none() {
7327 self.state.npc_verb_target = Some(opened.npc_id.clone());
7328 }
7329 let label = opened.npc_label.clone();
7330 let banner = if !opened.trade_allowed {
7331 Some("Trade is unavailable right now.".to_string())
7332 } else {
7333 None
7334 };
7335 self.state.show_npc_chat = true;
7336 self.state.npc_chat = Some(NpcChatState {
7337 npc_id: opened.npc_id,
7338 npc_label: opened.npc_label,
7339 lines: if opened.greeting.is_empty() {
7340 vec![]
7341 } else {
7342 vec![format!("{label}: {}", opened.greeting)]
7343 },
7344 input: String::new(),
7345 pending: opened.greeting.is_empty(),
7346 talk_depth: opened.talk_depth,
7347 trade_allowed: opened.trade_allowed,
7348 banner,
7349 suggested_topics: opened.suggested_topics,
7350 });
7351 }
7352 SessionEvent::NpcTalkPending(_) => {
7353 if let Some(chat) = self.state.npc_chat.as_mut() {
7354 chat.pending = true;
7355 }
7356 }
7357 SessionEvent::NpcTalkReply(reply) => {
7358 if let Some(chat) = self.state.npc_chat.as_mut() {
7359 if chat.npc_id == reply.npc_id {
7360 chat.pending = false;
7361 if reply.trade_disabled {
7362 chat.trade_allowed = false;
7363 chat.banner = Some("Trade is unavailable right now.".to_string());
7364 }
7365 if reply.wind_down {
7366 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
7367 if chat.banner.is_none() {
7368 chat.banner =
7369 Some("They're wrapping up — keep it brief.".to_string());
7370 }
7371 }
7372 chat.lines
7373 .push(format!("{}: {}", chat.npc_label, reply.line));
7374 }
7375 }
7376 }
7377 SessionEvent::NpcTalkClosed(closed) => {
7378 if self
7379 .state
7380 .npc_chat
7381 .as_ref()
7382 .is_some_and(|c| c.npc_id == closed.npc_id)
7383 {
7384 self.state.show_npc_chat = false;
7385 self.state.npc_chat = None;
7386 }
7387 }
7388 SessionEvent::NpcTalkError(err) => {
7389 self.state.push_log(format!("Talk failed: {}", err.reason));
7390 if let Some(chat) = self.state.npc_chat.as_mut() {
7391 chat.pending = false;
7392 }
7393 }
7394 SessionEvent::UseResult(result) => {
7395 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
7398 *qty = qty.saturating_sub(1);
7399 if *qty == 0 {
7400 self.state.inventory.remove(&result.template_id);
7401 }
7402 }
7403 }
7404 SessionEvent::QuestOffer(offer) => {
7405 self.state.pending_quest_offer = Some(offer.clone());
7406 self.state.show_quest_offer = true;
7407 self.state
7408 .push_log(format!("Quest offered: {}", offer.title));
7409 }
7410 SessionEvent::QuestAccepted(notice) => {
7411 self.state.show_quest_offer = false;
7412 self.state.pending_quest_offer = None;
7413 self.state.push_log(notice.message);
7414 }
7415 SessionEvent::QuestWithdrawn(notice) => {
7416 self.state.show_quest_menu = false;
7417 self.state.quest_withdraw_confirm = false;
7418 self.state.push_log(notice.message);
7419 }
7420 SessionEvent::QuestStepCompleted(notice) => {
7421 self.state.push_log(notice.message);
7422 }
7423 SessionEvent::QuestCompleted(notice) => {
7424 self.state.push_log(notice.message);
7425 }
7426 SessionEvent::Disconnected { reason } => {
7427 self.state.clear_harvest_state();
7428 self.state.connected = false;
7429 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
7430 if let Some(r) = &self.state.disconnect_reason {
7431 self.state.push_log(format!("Disconnected: {r}"));
7432 } else {
7433 self.state.push_log("Disconnected from server");
7434 }
7435 }
7436 }
7437 Ok(())
7438 }
7439
7440 pub fn is_connected(&self) -> bool {
7441 self.state.connected
7442 }
7443
7444 pub fn close_overlays(&mut self) {
7445 self.state.show_stats = false;
7446 self.state.show_craft_menu = false;
7447 self.state.show_plot_build_menu = false;
7448 self.state.show_shop_menu = false;
7449 self.state.shop_catalog = None;
7450 self.state.show_npc_verb_menu = false;
7451 self.state.npc_verb_target = None;
7452 self.state.show_npc_chat = false;
7453 self.state.npc_chat = None;
7454 self.state.show_inventory_menu = false;
7455 self.state.show_loadout_menu = false;
7456 self.state.show_rotation_editor = false;
7457 self.state.rotation_editor.reset();
7458 self.state.show_rename_prompt = false;
7459 self.state.show_worker_rename = false;
7460 self.state.rename_buffer.clear();
7461 self.state.show_move_picker = false;
7462 self.state.move_picker = None;
7463 self.state.show_destroy_picker = false;
7464 self.state.destroy_confirm_pending = false;
7465 self.state.destroy_picker = None;
7466 self.state.show_quest_offer = false;
7467 self.state.pending_quest_offer = None;
7468 self.state.show_quest_menu = false;
7469 self.state.quest_withdraw_confirm = false;
7470 self.state.show_workers_menu = false;
7471 self.close_worker_give_picker();
7472 self.close_worker_give_target_picker();
7473 self.close_worker_take_picker();
7474 self.close_worker_teach_picker();
7475 self.state.worker_route_editor = None;
7476 self.state.claim_mode = None;
7477 self.state.relocate_mode = None;
7478 self.state.sell_plot_confirm = None;
7479 self.state.sell_plot_armed_at = None;
7480 self.close_farm_access_panel();
7481 if self.state.show_plant_menu {
7482 self.close_plant_menu();
7483 }
7484 }
7485
7486 pub fn back_on_esc(&mut self) -> bool {
7488 if self.state.social_chat.composer_open() {
7489 self.state.social_chat.close_composer();
7490 return true;
7491 }
7492 if self.state.player_verbs.open {
7493 self.state.player_verbs.close();
7494 return true;
7495 }
7496 if self.state.whisper_pouch_ui.open {
7497 self.state.whisper_pouch_ui.open = false;
7498 return true;
7499 }
7500 if self.state.trade_ui.panel.is_some() {
7501 self.state.trade_ui.close();
7503 return true;
7504 }
7505 if self.state.show_rename_prompt {
7506 self.cancel_rename_prompt();
7507 return true;
7508 }
7509 if self.state.show_worker_rename {
7510 self.cancel_worker_rename();
7511 return true;
7512 }
7513 if self.state.show_destroy_picker {
7514 if self.state.destroy_confirm_pending {
7515 self.cancel_destroy_confirm();
7516 } else {
7517 self.close_destroy_picker();
7518 }
7519 return true;
7520 }
7521 if self.state.claim_mode.is_some() {
7522 self.cancel_claim_mode();
7523 return true;
7524 }
7525 if self.state.relocate_mode.is_some() {
7526 self.cancel_relocate_mode();
7527 return true;
7528 }
7529 if self.state.show_plant_menu {
7530 self.close_plant_menu();
7531 return true;
7532 }
7533 if self.state.show_farm_access {
7534 self.close_farm_access_panel();
7535 return true;
7536 }
7537 if self.state.sell_plot_confirm.is_some() {
7538 self.state.sell_plot_confirm = None;
7539 self.state.sell_plot_armed_at = None;
7540 self.state.push_log("Sell cancelled");
7541 return true;
7542 }
7543 if self.state.show_move_picker {
7544 self.close_move_picker();
7545 return true;
7546 }
7547 if self.state.show_rotation_editor {
7548 match self.state.rotation_editor.mode {
7549 RotationEditorMode::List => {
7550 self.state.show_rotation_editor = false;
7551 self.state.rotation_editor.reset();
7552 }
7553 RotationEditorMode::EditLabel => {
7554 self.state.rotation_editor.label_buffer.clear();
7555 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
7556 }
7557 RotationEditorMode::PickAbility => {
7558 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
7559 }
7560 RotationEditorMode::EditSequence => {
7561 self.state.rotation_editor.draft = None;
7562 self.state.rotation_editor.mode = RotationEditorMode::List;
7563 }
7564 }
7565 return true;
7566 }
7567 if self.state.show_inventory_menu {
7568 self.close_inventory_menu();
7569 return true;
7570 }
7571 if self.state.show_craft_menu {
7572 self.close_craft_menu();
7573 return true;
7574 }
7575 if self.state.show_plot_build_menu {
7576 self.close_plot_build_menu();
7577 return true;
7578 }
7579 if self.state.show_keychain_menu {
7580 self.close_keychain_menu();
7581 return true;
7582 }
7583 if self.state.show_quest_offer {
7584 self.quest_offer_decline();
7585 return true;
7586 }
7587 if self.state.show_shop_menu {
7588 return false;
7590 }
7591 if self.state.bank_panel.is_some() {
7592 return false;
7593 }
7594 if self.state.storage_panel.is_some() {
7595 return false;
7596 }
7597 if self.state.market_panel.is_some() {
7598 return false;
7599 }
7600 if self.state.show_npc_chat {
7601 return false;
7603 }
7604 if self.state.show_npc_verb_menu {
7605 self.state.show_npc_verb_menu = false;
7606 self.state.npc_verb_target = None;
7607 return true;
7608 }
7609 if self.state.show_quest_menu {
7610 if self.state.quest_withdraw_confirm {
7611 self.state.quest_withdraw_confirm = false;
7612 } else {
7613 self.state.show_quest_menu = false;
7614 }
7615 return true;
7616 }
7617 if self.state.worker_route_editor.is_some() {
7618 if self.re_at_root_sheet() {
7620 let reopen = self.state.attending_worker_instance_id.clone();
7621 self.close_worker_route_editor();
7622 if let Some(id) = reopen {
7623 if let Some(idx) = self
7624 .state
7625 .hired_workers
7626 .iter()
7627 .position(|w| w.instance_id == id)
7628 {
7629 self.state.workers_menu_index = idx;
7630 self.state.show_workers_menu = true;
7631 }
7632 }
7633 } else {
7634 self.re_sheet_back();
7635 }
7636 return true;
7637 }
7638 if self.state.show_worker_give_picker {
7639 self.close_worker_give_picker();
7640 return true;
7641 }
7642 if self.state.show_worker_give_target_picker {
7643 self.close_worker_give_target_picker();
7644 return true;
7645 }
7646 if self.state.show_worker_take_picker {
7647 self.close_worker_take_picker();
7648 return true;
7649 }
7650 if self.state.show_worker_teach_picker {
7651 self.close_worker_teach_picker();
7652 return true;
7653 }
7654 if self.state.show_workers_menu {
7655 self.close_workers_menu_ui();
7656 return true;
7657 }
7658 if self.state.show_loadout_menu {
7659 self.state.show_loadout_menu = false;
7660 return true;
7661 }
7662 if self.state.show_stats {
7663 self.state.show_stats = false;
7664 return true;
7665 }
7666 if self.state.show_equip_menu {
7667 self.state.show_equip_menu = false;
7668 return true;
7669 }
7670 false
7671 }
7672
7673 pub fn toggle_stats(&mut self) {
7674 self.state.show_stats = !self.state.show_stats;
7675 if self.state.show_stats {
7676 self.state.character_sheet_tab = CharacterSheetTab::Character;
7677 self.state.show_craft_menu = false;
7678 self.state.show_shop_menu = false;
7679 self.state.shop_catalog = None;
7680 self.state.show_inventory_menu = false;
7681 self.state.show_equip_menu = false;
7682 }
7683 }
7684
7685 pub fn toggle_equip_menu(&mut self) {
7686 self.state.show_equip_menu = !self.state.show_equip_menu;
7687 if self.state.show_equip_menu {
7688 self.state.show_stats = false;
7689 self.state.show_craft_menu = false;
7690 self.state.show_shop_menu = false;
7691 self.state.shop_catalog = None;
7692 self.state.show_inventory_menu = false;
7693 self.state.show_loadout_menu = false;
7694 }
7695 }
7696
7697 pub fn cycle_character_sheet_tab(&mut self) {
7698 if self.state.show_stats {
7699 self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
7700 }
7701 }
7702
7703 pub fn set_ledger_period_digit(&mut self, c: char) {
7704 if self.state.show_stats {
7705 if let Some(p) = LedgerPeriod::from_digit(c) {
7706 self.state.ledger_period = p;
7707 self.state.character_sheet_tab = CharacterSheetTab::Ledger;
7708 }
7709 }
7710 }
7711
7712 pub fn cycle_ledger_period(&mut self) {
7713 if self.state.show_stats && self.state.character_sheet_tab == CharacterSheetTab::Ledger {
7714 self.state.ledger_period = self.state.ledger_period.cycle();
7715 }
7716 }
7717
7718 pub fn open_inventory_menu(&mut self) {
7719 self.state.show_inventory_menu = true;
7720 self.state.show_craft_menu = false;
7721 self.state.show_shop_menu = false;
7722 self.state.shop_catalog = None;
7723 self.state.show_stats = false;
7724 self.state.show_move_picker = false;
7725 self.state.move_picker = None;
7726 self.state.show_destroy_picker = false;
7727 self.state.destroy_confirm_pending = false;
7728 self.state.destroy_picker = None;
7729 self.state.show_rename_prompt = false;
7730 self.state.rename_plot_id = None;
7731 self.state.rename_buffer.clear();
7732 self.state.inventory_filter_focused = false;
7733 self.state.clamp_inventory_indices();
7734 }
7735
7736 pub fn close_inventory_menu(&mut self) {
7737 self.state.show_inventory_menu = false;
7738 self.state.show_move_picker = false;
7739 self.state.move_picker = None;
7740 self.close_grant_picker();
7741 self.state.show_destroy_picker = false;
7742 self.state.destroy_confirm_pending = false;
7743 self.state.destroy_picker = None;
7744 self.state.show_rename_prompt = false;
7745 self.state.rename_plot_id = None;
7746 self.state.rename_buffer.clear();
7747 self.state.inventory_filter_focused = false;
7748 }
7749
7750 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
7751 let Some(row) = self.state.inventory_selected_row() else {
7752 anyhow::bail!("inventory empty");
7753 };
7754 if GameState::is_property_deed_template(&row.stack.template_id) {
7755 let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
7756 anyhow::bail!("deed has no plot id");
7757 };
7758 let label = self
7759 .state
7760 .property_plots
7761 .iter()
7762 .find(|p| p.plot_id == plot_id)
7763 .map(|p| {
7764 if p.label.trim().is_empty() {
7765 p.plot_code.clone()
7766 } else {
7767 p.label.clone()
7768 }
7769 })
7770 .unwrap_or_else(|| {
7771 row.stack
7772 .display_name
7773 .clone()
7774 .unwrap_or_else(|| "plot".into())
7775 });
7776 self.state.rename_buffer = label;
7777 self.state.rename_plot_id = Some(plot_id);
7778 self.state.highlighted_plot_id = Some(plot_id);
7779 self.state.show_rename_prompt = true;
7780 self.state.show_worker_rename = false;
7781 self.state.show_move_picker = false;
7782 self.state.show_destroy_picker = false;
7783 self.state.destroy_confirm_pending = false;
7784 return Ok(());
7785 }
7786 if !self.state.row_is_renameable_container(&row) {
7787 anyhow::bail!("only storage containers or deeds can be renamed");
7788 }
7789 let current = row
7790 .stack
7791 .display_name
7792 .clone()
7793 .unwrap_or_else(|| row.stack.template_id.clone());
7794 self.state.rename_buffer = current;
7795 self.state.rename_plot_id = None;
7796 self.state.show_rename_prompt = true;
7797 self.state.show_worker_rename = false;
7798 self.state.show_move_picker = false;
7799 self.state.show_destroy_picker = false;
7800 self.state.destroy_confirm_pending = false;
7801 Ok(())
7802 }
7803
7804 pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
7806 let Some(plot) = self.state.my_plot_under_player().cloned() else {
7807 anyhow::bail!("stand on your plot to rename it");
7808 };
7809 let label = if plot.label.trim().is_empty() {
7810 plot.plot_code.clone()
7811 } else {
7812 plot.label.clone()
7813 };
7814 self.state.rename_buffer = label;
7815 self.state.rename_plot_id = Some(plot.plot_id);
7816 self.state.highlighted_plot_id = Some(plot.plot_id);
7817 self.state.show_rename_prompt = true;
7818 self.state.show_worker_rename = false;
7819 Ok(())
7820 }
7821
7822 pub fn cancel_rename_prompt(&mut self) {
7823 self.state.show_rename_prompt = false;
7824 self.state.rename_plot_id = None;
7825 self.state.rename_buffer.clear();
7826 }
7827
7828 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
7829 let name = self.state.rename_buffer.trim().to_string();
7830 if name.is_empty() {
7831 anyhow::bail!("name cannot be empty");
7832 }
7833 if let Some(plot_id) = self.state.rename_plot_id {
7834 if name.chars().count() > 48 {
7835 anyhow::bail!("label must be 1–48 characters");
7836 }
7837 self.seq += 1;
7838 self.session
7839 .submit_intent(Intent::RenamePropertyPlot {
7840 entity_id: self.state.entity_id,
7841 plot_id,
7842 label: name,
7843 seq: self.seq,
7844 })
7845 .await?;
7846 self.state.intents_sent += 1;
7847 self.state.show_rename_prompt = false;
7848 self.state.rename_plot_id = None;
7849 self.state.rename_buffer.clear();
7850 return Ok(());
7851 }
7852 if name.chars().count() > 32 {
7853 anyhow::bail!("name must be 1–32 characters");
7854 }
7855 let Some(row) = self.state.inventory_selected_row() else {
7856 anyhow::bail!("inventory empty");
7857 };
7858 let Some(instance_id) = row.stack.item_instance_id else {
7859 anyhow::bail!("item has no instance id");
7860 };
7861 self.seq += 1;
7862 self.session
7863 .submit_intent(Intent::RenameContainer {
7864 entity_id: self.state.entity_id,
7865 item_instance_id: instance_id,
7866 location: row.from.clone(),
7867 name,
7868 seq: self.seq,
7869 })
7870 .await?;
7871 self.state.intents_sent += 1;
7872 self.state.show_rename_prompt = false;
7873 self.state.rename_buffer.clear();
7874 Ok(())
7875 }
7876
7877 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
7878 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7879 anyhow::bail!("no worker selected");
7880 };
7881 self.state.rename_buffer = worker.label.clone();
7882 self.state.show_worker_rename = true;
7883 self.state.show_rename_prompt = false;
7884 Ok(())
7885 }
7886
7887 pub fn cancel_worker_rename(&mut self) {
7888 self.state.show_worker_rename = false;
7889 self.state.rename_buffer.clear();
7890 }
7891
7892 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
7893 let name = self.state.rename_buffer.trim().to_string();
7894 if name.is_empty() {
7895 anyhow::bail!("name cannot be empty");
7896 }
7897 if name.chars().count() > 32 {
7898 anyhow::bail!("name must be 1–32 characters");
7899 }
7900 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7901 anyhow::bail!("no worker selected");
7902 };
7903 let worker_instance_id = worker.instance_id.clone();
7904 self.seq += 1;
7905 self.session
7906 .submit_intent(Intent::RenameHiredWorker {
7907 entity_id: self.state.entity_id,
7908 worker_instance_id: worker_instance_id.clone(),
7909 name: name.clone(),
7910 seq: self.seq,
7911 })
7912 .await?;
7913 self.state.intents_sent += 1;
7914 if let Some(w) = self
7915 .state
7916 .hired_workers
7917 .iter_mut()
7918 .find(|w| w.instance_id == worker_instance_id)
7919 {
7920 w.label = name.clone();
7921 }
7922 if let Some(ed) = self.state.worker_route_editor.as_mut() {
7923 if ed.worker_instance_id == worker_instance_id {
7924 ed.worker_label = name.clone();
7925 }
7926 }
7927 self.state.show_worker_rename = false;
7928 self.state.rename_buffer.clear();
7929 self.state.push_log(format!("Renamed worker to \"{name}\""));
7930 Ok(())
7931 }
7932
7933 pub fn toggle_inventory_menu(&mut self) {
7934 if self.state.show_inventory_menu {
7935 self.close_inventory_menu();
7936 } else {
7937 self.open_inventory_menu();
7938 }
7939 }
7940
7941 pub fn inventory_menu_move(&mut self, delta: i32) {
7943 if self.state.show_grant_picker {
7944 let Some(picker) = self.state.grant_picker.as_ref() else {
7945 return;
7946 };
7947 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7948 let filter = picker.filter.clone();
7949 let n = labels.len();
7950 if n == 0 {
7951 return;
7952 }
7953 self.state.grant_picker_index =
7954 step_filtered_index(self.state.grant_picker_index, delta, n, |i| {
7955 list_label_matches(&labels[i], &filter)
7956 });
7957 return;
7958 }
7959 if self.state.show_move_picker {
7960 let Some(picker) = self.state.move_picker.as_ref() else {
7961 return;
7962 };
7963 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7964 let filter = picker.filter.clone();
7965 let n = labels.len();
7966 if n == 0 {
7967 return;
7968 }
7969 self.state.move_picker_index =
7970 step_filtered_index(self.state.move_picker_index, delta, n, |i| {
7971 list_label_matches(&labels[i], &filter)
7972 });
7973 self.state.clamp_move_picker_quantity();
7974 return;
7975 }
7976 let n = self.state.inventory_selectable_rows().len();
7977 if n == 0 {
7978 return;
7979 }
7980 let idx = self.state.inventory_menu_index as i32;
7981 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
7982 }
7983
7984 pub fn inventory_menu_page(&mut self, pages: i32) {
7986 if self.state.show_grant_picker {
7987 let Some(picker) = self.state.grant_picker.as_ref() else {
7988 return;
7989 };
7990 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7991 let filter = picker.filter.clone();
7992 let n = labels.len();
7993 self.state.grant_picker_index =
7994 page_filtered_index(self.state.grant_picker_index, pages, n, |i| {
7995 list_label_matches(&labels[i], &filter)
7996 });
7997 return;
7998 }
7999 if self.state.show_move_picker {
8000 let Some(picker) = self.state.move_picker.as_ref() else {
8001 return;
8002 };
8003 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8004 let filter = picker.filter.clone();
8005 let n = labels.len();
8006 self.state.move_picker_index =
8007 page_filtered_index(self.state.move_picker_index, pages, n, |i| {
8008 list_label_matches(&labels[i], &filter)
8009 });
8010 self.state.clamp_move_picker_quantity();
8011 return;
8012 }
8013 let n = self.state.inventory_selectable_rows().len();
8014 self.state.inventory_menu_index =
8015 page_list_index(self.state.inventory_menu_index, pages, n);
8016 }
8017
8018 pub fn cycle_inventory_tab(&mut self, forward: bool) {
8019 if self.state.show_move_picker
8020 || self.state.show_grant_picker
8021 || self.state.show_destroy_picker
8022 || self.state.show_rename_prompt
8023 || self.state.inventory_filter_focused
8024 {
8025 return;
8026 }
8027 self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
8028 self.state.inventory_menu_index = 0;
8029 self.state.clamp_inventory_indices();
8030 }
8031
8032 pub fn focus_inventory_filter(&mut self) {
8033 if self.state.show_grant_picker {
8034 if let Some(p) = self.state.grant_picker.as_mut() {
8035 p.filter_focused = true;
8036 }
8037 return;
8038 }
8039 if self.state.show_move_picker {
8040 if let Some(p) = self.state.move_picker.as_mut() {
8041 p.filter_focused = true;
8042 }
8043 return;
8044 }
8045 self.state.inventory_filter_focused = true;
8046 }
8047
8048 pub fn set_inventory_filter(&mut self, filter: String) {
8049 self.state.inventory_filter = filter;
8050 self.state.inventory_menu_index = 0;
8051 self.state.clamp_inventory_indices();
8052 }
8053
8054 pub fn append_inventory_filter_char(&mut self, ch: char) {
8055 if ch.is_control() {
8056 return;
8057 }
8058 if self.state.show_grant_picker {
8059 if let Some(p) = self.state.grant_picker.as_mut() {
8060 if p.filter_focused {
8061 p.filter.push(ch);
8062 self.state.grant_picker_index = 0;
8063 }
8064 }
8065 return;
8066 }
8067 if self.state.show_move_picker {
8068 if let Some(p) = self.state.move_picker.as_mut() {
8069 if p.filter_focused {
8070 p.filter.push(ch);
8071 self.state.move_picker_index = 0;
8072 self.state.clamp_move_picker_quantity();
8073 }
8074 }
8075 return;
8076 }
8077 if !self.state.inventory_filter_focused {
8078 return;
8079 }
8080 self.state.inventory_filter.push(ch);
8081 self.state.inventory_menu_index = 0;
8082 self.state.clamp_inventory_indices();
8083 }
8084
8085 pub fn inventory_filter_backspace(&mut self) {
8086 if self.state.show_grant_picker {
8087 if let Some(p) = self.state.grant_picker.as_mut() {
8088 if p.filter_focused {
8089 p.filter.pop();
8090 self.state.grant_picker_index = 0;
8091 }
8092 }
8093 return;
8094 }
8095 if self.state.show_move_picker {
8096 if let Some(p) = self.state.move_picker.as_mut() {
8097 if p.filter_focused {
8098 p.filter.pop();
8099 self.state.move_picker_index = 0;
8100 self.state.clamp_move_picker_quantity();
8101 }
8102 }
8103 return;
8104 }
8105 if !self.state.inventory_filter_focused {
8106 return;
8107 }
8108 self.state.inventory_filter.pop();
8109 self.state.inventory_menu_index = 0;
8110 self.state.clamp_inventory_indices();
8111 }
8112
8113 pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
8115 if self.state.show_grant_picker {
8116 if let Some(p) = self.state.grant_picker.as_mut() {
8117 if p.filter_focused {
8118 if !p.filter.is_empty() {
8119 p.filter.clear();
8120 self.state.grant_picker_index = 0;
8121 } else {
8122 p.filter_focused = false;
8123 }
8124 return true;
8125 }
8126 if !p.filter.is_empty() {
8127 p.filter.clear();
8128 self.state.grant_picker_index = 0;
8129 return true;
8130 }
8131 }
8132 return false;
8133 }
8134 if self.state.show_move_picker {
8135 if let Some(p) = self.state.move_picker.as_mut() {
8136 if p.filter_focused {
8137 if !p.filter.is_empty() {
8138 p.filter.clear();
8139 self.state.move_picker_index = 0;
8140 self.state.clamp_move_picker_quantity();
8141 } else {
8142 p.filter_focused = false;
8143 }
8144 return true;
8145 }
8146 if !p.filter.is_empty() {
8147 p.filter.clear();
8148 self.state.move_picker_index = 0;
8149 self.state.clamp_move_picker_quantity();
8150 return true;
8151 }
8152 }
8153 return false;
8154 }
8155 if self.state.inventory_filter_focused {
8156 if !self.state.inventory_filter.is_empty() {
8157 self.state.inventory_filter.clear();
8158 self.state.inventory_menu_index = 0;
8159 self.state.clamp_inventory_indices();
8160 } else {
8161 self.state.inventory_filter_focused = false;
8162 }
8163 return true;
8164 }
8165 if !self.state.inventory_filter.is_empty() {
8166 self.state.inventory_filter.clear();
8167 self.state.inventory_menu_index = 0;
8168 self.state.clamp_inventory_indices();
8169 return true;
8170 }
8171 false
8172 }
8173
8174 pub fn craft_menu_page(&mut self, pages: i32) {
8175 let n = self.state.blueprints.len();
8176 self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
8177 self.state.clamp_craft_batch_quantity();
8178 }
8179
8180 pub fn shop_menu_page(&mut self, pages: i32) {
8181 let n = self.state.shop_list_len();
8182 self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
8183 self.state.clamp_shop_quantity();
8184 }
8185
8186 pub fn workers_menu_page(&mut self, pages: i32) {
8187 let n = self.state.hired_workers.len();
8188 self.state.workers_menu_index = page_list_index(self.state.workers_menu_index, pages, n);
8189 }
8190
8191 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
8196 if self.state.show_destroy_picker {
8197 if self.state.destroy_confirm_pending {
8198 return self.confirm_destroy_item().await;
8199 }
8200 return self.request_destroy_confirm();
8201 }
8202 if self.state.show_grant_picker {
8203 return self.confirm_grant_picker().await;
8204 }
8205 if self.state.show_move_picker {
8206 return self.confirm_move_picker().await;
8207 }
8208 let Some(row) = self.state.inventory_selected_row() else {
8209 anyhow::bail!("inventory empty");
8210 };
8211 if row.is_equip_shell {
8212 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
8213 anyhow::bail!("not a worn item");
8214 };
8215 return self.equip_worn(slot, None).await;
8216 }
8217 if row.is_chest_shell {
8218 return self.open_chest_pickup_picker();
8219 }
8220 let template_id = row.stack.template_id.clone();
8221 let instance_id = row.stack.item_instance_id;
8222 let category = self.state.inventory_item_category(&template_id);
8223 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
8224
8225 if category == Some("weapon") {
8226 return self.equip_mainhand(Some(template_id)).await;
8227 }
8228 if category == Some("lodging") && on_person {
8229 if let Some(inst) = instance_id {
8230 return self.place_container(inst).await;
8231 }
8232 }
8233 if (category == Some("container") || category == Some("armor")) && on_person {
8234 if let Some(inst) = instance_id {
8235 let world_placeable =
8236 row.stack.world_placeable == Some(true) || template_id.contains("chest");
8237 if world_placeable {
8238 return self.place_container(inst).await;
8239 }
8240 if let Some(slot) = guess_body_slot(&template_id) {
8244 return self.equip_worn(slot, Some(inst)).await;
8245 }
8246 }
8247 }
8248 self.open_move_picker()
8252 }
8253
8254 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
8256 let Some(row) = self.state.inventory_selected_row() else {
8257 anyhow::bail!("inventory empty");
8258 };
8259 if row.from != flatland_protocol::InventoryLocation::Root {
8260 anyhow::bail!("select a consumable on your person");
8261 }
8262 if GameState::stack_is_item_grant(&row.stack) {
8263 return self.open_grant_target_picker();
8264 }
8265 if GameState::is_property_deed_template(&row.stack.template_id) {
8266 return self.open_move_picker();
8267 }
8268 let category = self.state.inventory_item_category(&row.stack.template_id);
8269 if category != Some("consumable") {
8270 anyhow::bail!("selected item is not consumable");
8271 }
8272 self.use_item(&row.stack.template_id).await
8273 }
8274
8275 pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
8277 let Some(row) = self.state.inventory_selected_row() else {
8278 anyhow::bail!("inventory empty");
8279 };
8280 if row.from != flatland_protocol::InventoryLocation::Root {
8281 anyhow::bail!("select a grant item on your person");
8282 }
8283 if !GameState::stack_is_item_grant(&row.stack) {
8284 anyhow::bail!("selected item does not grant onto gear");
8285 }
8286 let Some(grant_instance_id) = row.stack.item_instance_id else {
8287 anyhow::bail!("grant has no instance id");
8288 };
8289 let effect_id = GameState::grant_effect_id(&row.stack)
8290 .unwrap_or("?")
8291 .to_string();
8292 let mode = GameState::grant_mode(&row.stack).to_string();
8293 let options = self.state.grant_target_options(&row.stack);
8294 if options.is_empty() {
8295 anyhow::bail!("no valid gear to apply {effect_id} to");
8296 }
8297 let grant_label = row
8298 .stack
8299 .display_name
8300 .clone()
8301 .unwrap_or_else(|| row.stack.template_id.clone());
8302 self.state.show_grant_picker = true;
8303 self.state.grant_picker_index = 0;
8304 self.state.grant_picker = Some(GrantTargetPicker {
8305 grant_instance_id,
8306 grant_label,
8307 effect_id,
8308 mode,
8309 options,
8310 filter: String::new(),
8311 filter_focused: false,
8312 });
8313 Ok(())
8314 }
8315
8316 pub fn close_grant_picker(&mut self) {
8317 self.state.show_grant_picker = false;
8318 self.state.grant_picker = None;
8319 self.state.grant_picker_index = 0;
8320 }
8321
8322 pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
8323 let Some(picker) = self.state.grant_picker.clone() else {
8324 self.close_grant_picker();
8325 return Ok(());
8326 };
8327 let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
8328 self.close_grant_picker();
8329 return Ok(());
8330 };
8331 self.close_grant_picker();
8332 self.use_grant(picker.grant_instance_id, opt.target_instance_id)
8333 .await?;
8334 self.state
8335 .push_log(format!("Applying {} onto {}…", picker.effect_id, opt.label));
8336 Ok(())
8337 }
8338
8339 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
8343 let Some(row) = self.state.inventory_selected_row() else {
8344 anyhow::bail!("inventory empty");
8345 };
8346 if row.is_equip_shell {
8347 anyhow::bail!("this is a worn bag — press Enter to unequip it");
8348 }
8349 if row.is_chest_shell {
8350 return self.open_chest_pickup_picker();
8351 }
8352 let Some(instance_id) = row.stack.item_instance_id else {
8353 anyhow::bail!("item has no instance id");
8354 };
8355 let mut options = self.state.move_destinations_for(
8356 &row.from,
8357 row.from_parent_instance_id,
8358 row.stack.item_instance_id,
8359 &row.stack.template_id,
8360 );
8361 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
8362 let category = self.state.inventory_item_category(&row.stack.template_id);
8363 if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
8364 if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
8365 options.insert(
8366 0,
8367 MoveOption {
8368 label: "Sell plot to crown…".into(),
8369 kind: MoveOptionKind::SellPlotToCrown { plot_id },
8370 },
8371 );
8372 }
8373 }
8374 if on_person && category == Some("consumable") {
8375 if GameState::stack_is_item_grant(&row.stack) {
8376 options.insert(
8377 0,
8378 MoveOption {
8379 label: "Apply onto gear…".into(),
8380 kind: MoveOptionKind::GrantApply,
8381 },
8382 );
8383 } else {
8384 options.insert(
8385 0,
8386 MoveOption {
8387 label: "Use (eat / drink)".into(),
8388 kind: MoveOptionKind::Use,
8389 },
8390 );
8391 }
8392 }
8393 let item_label = row
8394 .stack
8395 .display_name
8396 .clone()
8397 .unwrap_or_else(|| row.stack.template_id.clone());
8398 let initial_qty = if row.stack.quantity > 1 {
8401 1
8402 } else {
8403 row.stack.quantity
8404 };
8405 self.state.move_picker = Some(MovePicker {
8406 item_instance_id: instance_id,
8407 from: row.from,
8408 item_label,
8409 template_id: row.stack.template_id.clone(),
8410 stack_quantity: row.stack.quantity,
8411 quantity: initial_qty.max(1),
8412 options,
8413 filter: String::new(),
8414 filter_focused: false,
8415 });
8416 self.state.move_picker_index = 0;
8417 self.state.show_move_picker = true;
8418 self.state.show_destroy_picker = false;
8419 self.state.destroy_confirm_pending = false;
8420 self.state.destroy_picker = None;
8421 self.state.clamp_move_picker_quantity();
8422 Ok(())
8423 }
8424
8425 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
8427 let Some(row) = self.state.inventory_selected_row() else {
8428 anyhow::bail!("inventory empty");
8429 };
8430 if !row.is_chest_shell {
8431 anyhow::bail!("not a placed chest");
8432 }
8433 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
8434 anyhow::bail!("not a placed chest");
8435 };
8436 let Some(instance_id) = row.stack.item_instance_id else {
8437 anyhow::bail!("chest has no instance id");
8438 };
8439 let chest = self
8440 .state
8441 .placed_containers
8442 .iter()
8443 .find(|c| c.id == *container_id)
8444 .cloned()
8445 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
8446 let (px, py) = self.state.player_position();
8447 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
8448 anyhow::bail!("too far from {}", chest.display_name);
8449 }
8450 if chest.locked && !chest.accessible {
8451 anyhow::bail!(
8452 "need the matching key for {} before picking it up",
8453 chest.display_name
8454 );
8455 }
8456 let options = self.state.chest_pickup_destinations(container_id);
8457 let item_label = row
8458 .stack
8459 .display_name
8460 .clone()
8461 .unwrap_or_else(|| row.stack.template_id.clone());
8462 self.state.move_picker = Some(MovePicker {
8463 item_instance_id: instance_id,
8464 from: row.from.clone(),
8465 item_label,
8466 template_id: row.stack.template_id.clone(),
8467 stack_quantity: 1,
8468 quantity: 1,
8469 options,
8470 filter: String::new(),
8471 filter_focused: false,
8472 });
8473 self.state.move_picker_index = 0;
8474 self.state.show_move_picker = true;
8475 self.state.show_destroy_picker = false;
8476 self.state.destroy_confirm_pending = false;
8477 self.state.destroy_picker = None;
8478 Ok(())
8479 }
8480
8481 pub fn close_move_picker(&mut self) {
8482 self.state.show_move_picker = false;
8483 self.state.move_picker = None;
8484 }
8485
8486 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
8487 self.state.move_picker_adjust_quantity(delta);
8488 }
8489
8490 pub fn move_picker_set_quantity_max(&mut self) {
8491 self.state.move_picker_set_quantity_max();
8492 }
8493
8494 pub fn move_picker_set_quantity_min(&mut self) {
8495 self.state.move_picker_set_quantity_min();
8496 }
8497
8498 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
8499 self.state.destroy_picker_adjust_quantity(delta);
8500 }
8501
8502 pub fn destroy_picker_set_quantity_max(&mut self) {
8503 self.state.destroy_picker_set_quantity_max();
8504 }
8505
8506 pub fn destroy_picker_set_quantity_min(&mut self) {
8507 self.state.destroy_picker_set_quantity_min();
8508 }
8509
8510 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
8511 let Some(picker) = self.state.move_picker.clone() else {
8512 self.close_move_picker();
8513 return Ok(());
8514 };
8515 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
8516 self.close_move_picker();
8517 return Ok(());
8518 };
8519 match option.kind {
8520 MoveOptionKind::Cancel => {
8521 self.close_move_picker();
8522 }
8523 MoveOptionKind::Use => {
8524 self.close_move_picker();
8525 self.use_item(&picker.template_id).await?;
8526 }
8527 MoveOptionKind::GrantApply => {
8528 self.close_move_picker();
8529 self.open_grant_target_picker()?;
8530 }
8531 MoveOptionKind::SellPlotToCrown { plot_id } => {
8532 self.close_move_picker();
8533 self.confirm_sell_plot_to_crown(plot_id).await?;
8534 }
8535 MoveOptionKind::RelocatePlaced { container_id } => {
8536 self.close_move_picker();
8537 self.state.show_inventory_menu = false;
8538 self.begin_relocate_container(&container_id)?;
8539 }
8540 MoveOptionKind::Drop => {
8541 self.close_move_picker();
8542 if self
8543 .state
8544 .hand_equipped_instance_ids()
8545 .contains(&picker.item_instance_id)
8546 {
8547 anyhow::bail!("unequip that item first");
8548 }
8549 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
8550 if self.state.deed_bound(&stack) {
8551 anyhow::bail!(
8552 "cannot drop a property deed — store it or trade it to another player"
8553 );
8554 }
8555 if self.state.key_drop_blocked(&stack) {
8556 anyhow::bail!("cannot drop the key while its chest is locked");
8557 }
8558 }
8559 self.drop_item(picker.item_instance_id, picker.from).await?;
8560 self.state
8561 .push_log(format!("Dropped {}", picker.item_label));
8562 }
8563 MoveOptionKind::PickupPlaced {
8564 container_id,
8565 nest_location,
8566 nest_parent_instance_id,
8567 } => {
8568 self.close_move_picker();
8569 self.pickup_container(container_id.clone()).await?;
8570 let nest_into_bag = nest_parent_instance_id.is_some()
8571 || !matches!(nest_location, flatland_protocol::InventoryLocation::Root);
8572 if nest_into_bag {
8573 self.move_item(
8574 picker.item_instance_id,
8575 flatland_protocol::InventoryLocation::Root,
8576 nest_location,
8577 nest_parent_instance_id,
8578 None,
8579 )
8580 .await?;
8581 self.state
8582 .push_log(format!("Picked up {} into bag", picker.item_label));
8583 } else {
8584 self.state
8585 .push_log(format!("Picked up {}", picker.item_label));
8586 }
8587 }
8588 MoveOptionKind::Move {
8589 location,
8590 parent_instance_id,
8591 } => {
8592 self.close_move_picker();
8593 let qty = if picker.quantity >= picker.stack_quantity {
8594 None
8595 } else {
8596 Some(picker.quantity)
8597 };
8598 self.move_item(
8599 picker.item_instance_id,
8600 picker.from,
8601 location,
8602 parent_instance_id,
8603 qty,
8604 )
8605 .await?;
8606 let moved = qty.unwrap_or(picker.stack_quantity);
8607 if moved >= picker.stack_quantity {
8608 self.state.push_log(format!("Moved {}", picker.item_label));
8609 } else {
8610 self.state.push_log(format!(
8611 "Moved {} ×{} of {}",
8612 picker.item_label, moved, picker.stack_quantity
8613 ));
8614 }
8615 }
8616 }
8617 Ok(())
8618 }
8619
8620 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
8622 let Some(row) = self.state.inventory_selected_row() else {
8623 anyhow::bail!("inventory empty");
8624 };
8625 if row.is_equip_shell {
8626 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
8627 }
8628 if row.is_chest_shell {
8629 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
8630 }
8631 let Some(inst) = row.stack.item_instance_id else {
8632 anyhow::bail!("item has no instance id");
8633 };
8634 if self.state.hand_equipped_instance_ids().contains(&inst) {
8635 anyhow::bail!("unequip that item first");
8636 }
8637 if self.state.deed_bound(&row.stack) {
8638 anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
8639 }
8640 if self.state.key_drop_blocked(&row.stack) {
8641 anyhow::bail!("cannot drop the key while its chest is locked");
8642 }
8643 let label = row
8644 .stack
8645 .display_name
8646 .clone()
8647 .unwrap_or_else(|| row.stack.template_id.clone());
8648 self.drop_item(inst, row.from).await?;
8649 self.state.push_log(format!("Dropped {label}"));
8650 Ok(())
8651 }
8652
8653 pub async fn drop_item(
8654 &mut self,
8655 item_instance_id: uuid::Uuid,
8656 from: flatland_protocol::InventoryLocation,
8657 ) -> anyhow::Result<()> {
8658 self.seq += 1;
8659 self.session
8660 .submit_intent(Intent::DropItem {
8661 entity_id: self.state.entity_id,
8662 item_instance_id,
8663 from,
8664 seq: self.seq,
8665 })
8666 .await?;
8667 self.state.intents_sent += 1;
8668 Ok(())
8669 }
8670
8671 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
8673 let Some(row) = self.state.inventory_selected_row() else {
8674 anyhow::bail!("inventory empty");
8675 };
8676 if row.is_equip_shell {
8677 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
8678 }
8679 if row.is_chest_shell {
8680 anyhow::bail!("can't destroy a placed chest from the inventory list");
8681 }
8682 let Some(instance_id) = row.stack.item_instance_id else {
8683 anyhow::bail!("item has no instance id");
8684 };
8685 if self
8686 .state
8687 .hand_equipped_instance_ids()
8688 .contains(&instance_id)
8689 {
8690 anyhow::bail!("unequip that item first");
8691 }
8692 if self.state.deed_bound(&row.stack) {
8693 anyhow::bail!(
8694 "cannot destroy a property deed — store it or trade it to another player"
8695 );
8696 }
8697 if self.state.key_drop_blocked(&row.stack) {
8698 anyhow::bail!("cannot destroy the key while its chest is locked");
8699 }
8700 let item_label = row
8701 .stack
8702 .display_name
8703 .clone()
8704 .unwrap_or_else(|| row.stack.template_id.clone());
8705 self.state.destroy_picker = Some(DestroyPicker {
8706 item_instance_id: instance_id,
8707 from: row.from,
8708 item_label,
8709 stack_quantity: row.stack.quantity,
8710 quantity: row.stack.quantity,
8711 });
8712 self.state.destroy_confirm_pending = false;
8713 self.state.show_destroy_picker = true;
8714 self.state.show_move_picker = false;
8715 self.state.move_picker = None;
8716 Ok(())
8717 }
8718
8719 pub fn close_destroy_picker(&mut self) {
8720 self.state.show_destroy_picker = false;
8721 self.state.destroy_confirm_pending = false;
8722 self.state.destroy_picker = None;
8723 }
8724
8725 pub fn cancel_destroy_confirm(&mut self) {
8726 self.state.destroy_confirm_pending = false;
8727 }
8728
8729 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
8730 if self.state.destroy_picker.is_none() {
8731 self.close_destroy_picker();
8732 return Ok(());
8733 }
8734 self.state.destroy_confirm_pending = true;
8735 Ok(())
8736 }
8737
8738 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
8739 let Some(picker) = self.state.destroy_picker.clone() else {
8740 self.close_destroy_picker();
8741 return Ok(());
8742 };
8743 let qty = if picker.quantity >= picker.stack_quantity {
8744 None
8745 } else {
8746 Some(picker.quantity)
8747 };
8748 self.destroy_item(picker.item_instance_id, picker.from, qty)
8749 .await?;
8750 let destroyed = qty.unwrap_or(picker.stack_quantity);
8751 if destroyed >= picker.stack_quantity {
8752 self.state
8753 .push_log(format!("Destroyed {}", picker.item_label));
8754 } else {
8755 self.state.push_log(format!(
8756 "Destroyed {} ×{} of {}",
8757 picker.item_label, destroyed, picker.stack_quantity
8758 ));
8759 }
8760 self.close_destroy_picker();
8761 Ok(())
8762 }
8763
8764 pub async fn destroy_item(
8765 &mut self,
8766 item_instance_id: uuid::Uuid,
8767 from: flatland_protocol::InventoryLocation,
8768 quantity: Option<u32>,
8769 ) -> anyhow::Result<()> {
8770 self.seq += 1;
8771 self.session
8772 .submit_intent(Intent::DestroyItem {
8773 entity_id: self.state.entity_id,
8774 item_instance_id,
8775 from,
8776 quantity,
8777 seq: self.seq,
8778 })
8779 .await?;
8780 self.state.intents_sent += 1;
8781 Ok(())
8782 }
8783
8784 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
8786 if let Some(row) = self.state.inventory_selected_row() {
8787 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
8788 return self.toggle_placed_chest_lock(container_id).await;
8789 }
8790 }
8791 self.toggle_nearby_chest_lock().await
8792 }
8793
8794 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
8795 let chest = self
8796 .state
8797 .placed_containers
8798 .iter()
8799 .find(|c| c.id == container_id)
8800 .cloned()
8801 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
8802 let (px, py) = self.state.player_position();
8803 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
8804 anyhow::bail!("too far from {}", chest.display_name);
8805 }
8806 if !chest.accessible && chest.locked {
8807 anyhow::bail!(
8808 "need the matching key for {} (each crafted chest has its own key)",
8809 chest.display_name
8810 );
8811 }
8812 let lock = !chest.locked;
8813 self.set_container_locked(
8814 flatland_protocol::InventoryLocation::Placed {
8815 container_id: chest.id.clone(),
8816 },
8817 lock,
8818 )
8819 .await?;
8820 self.state.push_log(if lock {
8821 format!("Locked {}", chest.display_name)
8822 } else {
8823 format!("Unlocked {}", chest.display_name)
8824 });
8825 Ok(())
8826 }
8827
8828 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
8830 let chest = self
8831 .state
8832 .nearest_placed_container(CONTAINER_RANGE_M)
8833 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
8834 self.toggle_placed_chest_lock(&chest.id).await
8835 }
8836
8837 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
8838 self.equip_mainhand(None).await
8839 }
8840
8841 pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
8842 if !self.state.is_alive() {
8843 anyhow::bail!("you are dead");
8844 }
8845 self.seq += 1;
8846 self.session
8847 .submit_intent(Intent::EquipOffhand {
8848 entity_id: self.state.entity_id,
8849 template_id,
8850 instance_id: None,
8851 seq: self.seq,
8852 })
8853 .await?;
8854 self.state.intents_sent += 1;
8855 Ok(())
8856 }
8857
8858 pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
8859 self.equip_offhand(None).await
8860 }
8861
8862 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
8863 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
8864 for slot in slots {
8865 self.equip_worn(slot, None).await?;
8866 }
8867 Ok(())
8868 }
8869
8870 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
8871 let (px, py) = self.state.player_position();
8872 let nearest = self
8873 .state
8874 .placed_containers
8875 .iter()
8876 .min_by(|a, b| {
8877 let da = (a.x - px).hypot(a.y - py);
8878 let db = (b.x - px).hypot(b.y - py);
8879 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
8880 })
8881 .cloned();
8882 let Some(chest) = nearest else {
8883 anyhow::bail!("no chest nearby");
8884 };
8885 if (chest.x - px).hypot(chest.y - py) > 2.0 {
8886 anyhow::bail!("too far from chest");
8887 }
8888 self.pickup_container(chest.id).await
8889 }
8890
8891 pub async fn equip_worn(
8892 &mut self,
8893 slot: BodySlot,
8894 instance_id: Option<uuid::Uuid>,
8895 ) -> anyhow::Result<()> {
8896 self.seq += 1;
8897 self.session
8898 .submit_intent(Intent::EquipWorn {
8899 entity_id: self.state.entity_id,
8900 slot,
8901 instance_id,
8902 seq: self.seq,
8903 })
8904 .await?;
8905 self.state.intents_sent += 1;
8906 Ok(())
8907 }
8908
8909 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
8910 self.seq += 1;
8911 self.session
8912 .submit_intent(Intent::PlaceContainer {
8913 entity_id: self.state.entity_id,
8914 item_instance_id,
8915 seq: self.seq,
8916 })
8917 .await?;
8918 self.state.intents_sent += 1;
8919 Ok(())
8920 }
8921
8922 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
8923 self.seq += 1;
8924 self.session
8925 .submit_intent(Intent::PickupContainer {
8926 entity_id: self.state.entity_id,
8927 container_id,
8928 seq: self.seq,
8929 })
8930 .await?;
8931 self.state.intents_sent += 1;
8932 Ok(())
8933 }
8934
8935 pub async fn move_item(
8936 &mut self,
8937 item_instance_id: uuid::Uuid,
8938 from: flatland_protocol::InventoryLocation,
8939 to: flatland_protocol::InventoryLocation,
8940 to_parent_instance_id: Option<uuid::Uuid>,
8941 quantity: Option<u32>,
8942 ) -> anyhow::Result<()> {
8943 self.seq += 1;
8944 self.session
8945 .submit_intent(Intent::MoveItem {
8946 entity_id: self.state.entity_id,
8947 item_instance_id,
8948 from,
8949 to,
8950 to_parent_instance_id,
8951 quantity,
8952 seq: self.seq,
8953 })
8954 .await?;
8955 self.state.intents_sent += 1;
8956 Ok(())
8957 }
8958
8959 pub async fn set_container_locked(
8960 &mut self,
8961 location: flatland_protocol::InventoryLocation,
8962 locked: bool,
8963 ) -> anyhow::Result<()> {
8964 self.seq += 1;
8965 self.session
8966 .submit_intent(Intent::SetContainerLocked {
8967 entity_id: self.state.entity_id,
8968 location,
8969 locked,
8970 seq: self.seq,
8971 })
8972 .await?;
8973 self.state.intents_sent += 1;
8974 Ok(())
8975 }
8976
8977 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
8978 if !self.state.is_alive() {
8979 anyhow::bail!("you are dead");
8980 }
8981 self.seq += 1;
8982 self.session
8983 .submit_intent(Intent::Use {
8984 entity_id: self.state.entity_id,
8985 template_id: template_id.to_string(),
8986 seq: self.seq,
8987 })
8988 .await?;
8989 self.state.intents_sent += 1;
8990 Ok(())
8991 }
8992
8993 pub async fn use_grant(
8995 &mut self,
8996 grant_instance_id: uuid::Uuid,
8997 target_instance_id: uuid::Uuid,
8998 ) -> anyhow::Result<()> {
8999 if !self.state.is_alive() {
9000 anyhow::bail!("you are dead");
9001 }
9002 self.seq += 1;
9003 self.session
9004 .submit_intent(Intent::UseGrant {
9005 entity_id: self.state.entity_id,
9006 grant_instance_id,
9007 target_instance_id,
9008 seq: self.seq,
9009 })
9010 .await?;
9011 self.state.intents_sent += 1;
9012 Ok(())
9013 }
9014
9015 pub fn open_craft_menu(&mut self) {
9016 self.state.show_craft_menu = true;
9017 self.state.show_shop_menu = false;
9018 self.state.shop_catalog = None;
9019 self.state.show_stats = false;
9020 self.state.show_inventory_menu = false;
9021 if self.state.blueprints.is_empty() {
9022 self.state.craft_menu_index = 0;
9023 self.state.craft_batch_quantity = 1;
9024 return;
9025 }
9026 self.state.craft_menu_index = self
9027 .state
9028 .craft_menu_index
9029 .min(self.state.blueprints.len() - 1);
9030 if let Some(idx) = self
9031 .state
9032 .blueprints
9033 .iter()
9034 .position(|bp| self.state.can_craft_blueprint(bp))
9035 {
9036 self.state.craft_menu_index = idx;
9037 }
9038 self.state.clamp_craft_batch_quantity();
9039 }
9040
9041 pub fn close_craft_menu(&mut self) {
9042 self.state.show_craft_menu = false;
9043 }
9044
9045 pub fn toggle_keychain_menu(&mut self) {
9046 if self.state.show_keychain_menu {
9047 self.close_keychain_menu();
9048 } else {
9049 self.state.show_keychain_menu = true;
9050 self.state.show_craft_menu = false;
9051 self.state.show_shop_menu = false;
9052 self.state.show_inventory_menu = false;
9053 let n = self.state.keychain_entries().len();
9054 if n == 0 {
9055 self.state.keychain_menu_index = 0;
9056 } else {
9057 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
9058 }
9059 }
9060 }
9061
9062 pub fn close_keychain_menu(&mut self) {
9063 self.state.show_keychain_menu = false;
9064 }
9065
9066 pub fn keychain_menu_move(&mut self, delta: i32) {
9067 let n = self.state.keychain_entries().len();
9068 if n == 0 {
9069 self.state.keychain_menu_index = 0;
9070 return;
9071 }
9072 let idx = self.state.keychain_menu_index as i32 + delta;
9073 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
9074 }
9075
9076 pub fn keychain_menu_page(&mut self, pages: i32) {
9077 let n = self.state.keychain_entries().len();
9078 self.state.keychain_menu_index = page_list_index(self.state.keychain_menu_index, pages, n);
9079 }
9080
9081 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
9082 if !self.state.is_alive() {
9083 anyhow::bail!("you are dead");
9084 }
9085 let entries = self.state.keychain_entries();
9086 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
9087 anyhow::bail!("nothing selected");
9088 };
9089 let Some(instance_id) = entry.stack.item_instance_id else {
9090 anyhow::bail!("key has no instance id");
9091 };
9092 if entry.stowed {
9093 self.move_item(
9094 instance_id,
9095 flatland_protocol::InventoryLocation::Keychain,
9096 flatland_protocol::InventoryLocation::Root,
9097 None,
9098 Some(1),
9099 )
9100 .await
9101 } else {
9102 self.move_item(
9103 instance_id,
9104 flatland_protocol::InventoryLocation::Root,
9105 flatland_protocol::InventoryLocation::Keychain,
9106 None,
9107 Some(1),
9108 )
9109 .await
9110 }
9111 }
9112
9113 pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
9114 let npc_id = self.state.shop_catalog.as_ref().map(|c| c.npc_id.clone());
9115 self.state.show_shop_menu = false;
9116 self.state.shop_catalog = None;
9117 self.state.clear_shop_trade_log();
9118 if let Some(npc_id) = npc_id {
9119 self.seq += 1;
9120 self.session
9121 .submit_intent(Intent::ShopClose {
9122 entity_id: self.state.entity_id,
9123 npc_id,
9124 seq: self.seq,
9125 })
9126 .await?;
9127 self.state.intents_sent += 1;
9128 }
9129 Ok(())
9130 }
9131
9132 pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
9133 let Some(panel) = self.state.bank_panel.clone() else {
9134 return Ok(());
9135 };
9136 self.seq += 1;
9137 self.session
9138 .submit_intent(Intent::BankDeposit {
9139 entity_id: self.state.entity_id,
9140 npc_id: panel.npc_id,
9141 amount_copper,
9142 seq: self.seq,
9143 })
9144 .await?;
9145 self.state.intents_sent += 1;
9146 Ok(())
9147 }
9148
9149 pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
9150 let Some(panel) = self.state.bank_panel.clone() else {
9151 return Ok(());
9152 };
9153 self.seq += 1;
9154 self.session
9155 .submit_intent(Intent::BankWithdraw {
9156 entity_id: self.state.entity_id,
9157 npc_id: panel.npc_id,
9158 amount_copper,
9159 seq: self.seq,
9160 })
9161 .await?;
9162 self.state.intents_sent += 1;
9163 Ok(())
9164 }
9165
9166 pub async fn bank_transfer(
9167 &mut self,
9168 to_character_id: Option<uuid::Uuid>,
9169 to_name: String,
9170 amount_copper: u64,
9171 ) -> anyhow::Result<()> {
9172 let Some(panel) = self.state.bank_panel.clone() else {
9173 return Ok(());
9174 };
9175 self.seq += 1;
9176 self.session
9177 .submit_intent(Intent::BankTransfer {
9178 entity_id: self.state.entity_id,
9179 npc_id: panel.npc_id,
9180 to_character_id,
9181 to_name,
9182 amount_copper,
9183 seq: self.seq,
9184 })
9185 .await?;
9186 self.state.intents_sent += 1;
9187 Ok(())
9188 }
9189
9190 pub fn bank_menu_move(&mut self, delta: i32) {
9191 let n = self.state.bank_menu_options().len();
9192 if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
9193 return;
9194 }
9195 let idx = self.state.bank_menu_index as i32;
9196 self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9197 }
9198
9199 pub fn storage_menu_move(&mut self, delta: i32) {
9200 let n = self.state.storage_menu_options().len();
9201 if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
9202 return;
9203 }
9204 let idx = self.state.storage_menu_index as i32;
9205 self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9206 }
9207
9208 pub fn storage_pick_move(&mut self, delta: i32) {
9209 let n = match &self.state.storage_ui_mode {
9210 StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
9211 StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
9212 self.state.storage_vault_options().len()
9213 }
9214 StorageUiMode::Menu
9215 | StorageUiMode::StoreAmount { .. }
9216 | StorageUiMode::TakeAmount { .. }
9217 | StorageUiMode::ShipAmount { .. } => 0,
9218 };
9219 if n == 0 {
9220 return;
9221 }
9222 match &mut self.state.storage_ui_mode {
9223 StorageUiMode::StorePick { index }
9224 | StorageUiMode::TakePick { index }
9225 | StorageUiMode::ShipPick { index, .. } => {
9226 *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9227 }
9228 StorageUiMode::Menu
9229 | StorageUiMode::StoreAmount { .. }
9230 | StorageUiMode::TakeAmount { .. }
9231 | StorageUiMode::ShipAmount { .. } => {}
9232 }
9233 }
9234
9235 pub fn storage_ui_back(&mut self) {
9236 self.state.storage_ui_mode = match &self.state.storage_ui_mode {
9237 StorageUiMode::StoreAmount { pick_index, .. } => {
9238 StorageUiMode::StorePick { index: *pick_index }
9239 }
9240 StorageUiMode::TakeAmount { pick_index, .. } => {
9241 StorageUiMode::TakePick { index: *pick_index }
9242 }
9243 StorageUiMode::ShipAmount {
9244 dest_building_id,
9245 dest_label,
9246 pick_index,
9247 ..
9248 } => StorageUiMode::ShipPick {
9249 dest_building_id: dest_building_id.clone(),
9250 dest_label: dest_label.clone(),
9251 index: *pick_index,
9252 },
9253 StorageUiMode::StorePick { .. }
9254 | StorageUiMode::TakePick { .. }
9255 | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
9256 StorageUiMode::Menu => StorageUiMode::Menu,
9257 };
9258 }
9259
9260 pub fn storage_amount_append_char(&mut self, c: char) {
9261 match &mut self.state.storage_ui_mode {
9262 StorageUiMode::StoreAmount { input, .. }
9263 | StorageUiMode::TakeAmount { input, .. }
9264 | StorageUiMode::ShipAmount { input, .. } => {
9265 if c.is_ascii_digit() && input.len() < 8 {
9266 input.push(c);
9267 }
9268 }
9269 _ => {}
9270 }
9271 }
9272
9273 pub fn storage_amount_backspace(&mut self) {
9274 match &mut self.state.storage_ui_mode {
9275 StorageUiMode::StoreAmount { input, .. }
9276 | StorageUiMode::TakeAmount { input, .. }
9277 | StorageUiMode::ShipAmount { input, .. } => {
9278 input.pop();
9279 }
9280 _ => {}
9281 }
9282 }
9283
9284 pub fn storage_ui_typing(&self) -> bool {
9285 matches!(
9286 self.state.storage_ui_mode,
9287 StorageUiMode::StoreAmount { .. }
9288 | StorageUiMode::TakeAmount { .. }
9289 | StorageUiMode::ShipAmount { .. }
9290 )
9291 }
9292
9293 pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
9294 match self.state.storage_ui_mode.clone() {
9295 StorageUiMode::Menu => {
9296 let index = self.state.storage_menu_index;
9297 match index {
9298 0 => {
9299 let opts = self.state.storage_store_options();
9300 if opts.is_empty() {
9301 self.state.push_log("Nothing loose to store.");
9302 return Ok(());
9303 }
9304 self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
9305 }
9306 1 => {
9307 let opts = self.state.storage_vault_options();
9308 if opts.is_empty() {
9309 self.state.push_log("Vault is empty.");
9310 return Ok(());
9311 }
9312 self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
9313 }
9314 n => {
9315 let dest = self
9316 .state
9317 .storage_panel
9318 .as_ref()
9319 .and_then(|p| p.ship_destinations.get(n - 2))
9320 .cloned();
9321 let Some(dest) = dest else {
9322 return Ok(());
9323 };
9324 let opts = self.state.storage_vault_options();
9325 if opts.is_empty() {
9326 self.state.push_log("Vault is empty — nothing to ship.");
9327 return Ok(());
9328 }
9329 self.state.storage_ui_mode = StorageUiMode::ShipPick {
9330 dest_building_id: dest.building_id,
9331 dest_label: dest.label,
9332 index: 0,
9333 };
9334 }
9335 }
9336 }
9337 StorageUiMode::StorePick { index } => {
9338 let opts = self.state.storage_store_options();
9339 let Some(opt) = opts.get(index) else {
9340 self.state.push_log("Nothing loose to store.");
9341 self.state.storage_ui_mode = StorageUiMode::Menu;
9342 return Ok(());
9343 };
9344 self.state.storage_ui_mode = StorageUiMode::StoreAmount {
9345 pick_index: index,
9346 item_instance_id: opt.item_instance_id,
9347 label: opt.label.clone(),
9348 max_qty: opt.quantity.max(1),
9349 input: String::new(),
9350 };
9351 }
9352 StorageUiMode::TakePick { index } => {
9353 let opts = self.state.storage_vault_options();
9354 let Some(opt) = opts.get(index) else {
9355 self.state.push_log("Vault is empty.");
9356 self.state.storage_ui_mode = StorageUiMode::Menu;
9357 return Ok(());
9358 };
9359 self.state.storage_ui_mode = StorageUiMode::TakeAmount {
9360 pick_index: index,
9361 item_instance_id: opt.item_instance_id,
9362 label: opt.label.clone(),
9363 max_qty: opt.quantity.max(1),
9364 input: String::new(),
9365 };
9366 }
9367 StorageUiMode::ShipPick {
9368 dest_building_id,
9369 dest_label,
9370 index,
9371 } => {
9372 let opts = self.state.storage_vault_options();
9373 let Some(opt) = opts.get(index) else {
9374 self.state.push_log("Vault is empty — nothing to ship.");
9375 self.state.storage_ui_mode = StorageUiMode::Menu;
9376 return Ok(());
9377 };
9378 self.state.storage_ui_mode = StorageUiMode::ShipAmount {
9379 dest_building_id,
9380 dest_label,
9381 pick_index: index,
9382 item_instance_id: opt.item_instance_id,
9383 label: opt.label.clone(),
9384 max_qty: opt.quantity.max(1),
9385 input: String::new(),
9386 };
9387 }
9388 StorageUiMode::StoreAmount {
9389 item_instance_id,
9390 max_qty,
9391 input,
9392 ..
9393 } => {
9394 let Some(qty) = parse_storage_quantity(&input) else {
9395 self.state.push_log("Enter a quantity (blank or 0 = all).");
9396 return Ok(());
9397 };
9398 let qty = qty.map(|n| n.min(max_qty).max(1));
9399 self.storage_store(item_instance_id, qty).await?;
9400 self.state.storage_ui_mode = StorageUiMode::Menu;
9401 }
9402 StorageUiMode::TakeAmount {
9403 item_instance_id,
9404 max_qty,
9405 input,
9406 ..
9407 } => {
9408 let Some(qty) = parse_storage_quantity(&input) else {
9409 self.state.push_log("Enter a quantity (blank or 0 = all).");
9410 return Ok(());
9411 };
9412 let qty = qty.map(|n| n.min(max_qty).max(1));
9413 self.storage_take(item_instance_id, qty).await?;
9414 self.state.storage_ui_mode = StorageUiMode::Menu;
9415 }
9416 StorageUiMode::ShipAmount {
9417 dest_building_id,
9418 item_instance_id,
9419 max_qty,
9420 input,
9421 ..
9422 } => {
9423 let Some(qty) = parse_storage_quantity(&input) else {
9424 self.state.push_log("Enter a quantity (blank or 0 = all).");
9425 return Ok(());
9426 };
9427 let qty = qty.map(|n| n.min(max_qty).max(1));
9428 self.storage_ship(dest_building_id, item_instance_id, qty)
9429 .await?;
9430 self.state.storage_ui_mode = StorageUiMode::Menu;
9431 }
9432 }
9433 Ok(())
9434 }
9435
9436 pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
9437 match self.state.bank_ui_mode.clone() {
9438 BankUiMode::Menu => {
9439 let choice = self
9440 .state
9441 .bank_menu_options()
9442 .get(self.state.bank_menu_index)
9443 .copied()
9444 .unwrap_or("Deposit…");
9445 match choice {
9446 "Withdraw…" => {
9447 self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
9448 input: String::new(),
9449 };
9450 }
9451 "Deposit all" => self.bank_deposit(0).await?,
9452 "Withdraw all" => self.bank_withdraw(0).await?,
9453 "Transfer…" => {
9454 self.state.bank_ui_mode = BankUiMode::TransferName {
9455 input: String::new(),
9456 };
9457 }
9458 _ => {
9459 self.state.bank_ui_mode = BankUiMode::DepositAmount {
9460 input: String::new(),
9461 };
9462 }
9463 }
9464 }
9465 BankUiMode::DepositAmount { input } => {
9466 let Some(amount) = parse_bank_copper_amount(&input) else {
9467 self.state
9468 .push_log("Enter a copper amount (blank or 0 = everything on person).");
9469 return Ok(());
9470 };
9471 self.bank_deposit(amount).await?;
9472 self.state.bank_ui_mode = BankUiMode::Menu;
9473 }
9474 BankUiMode::WithdrawAmount { input } => {
9475 let Some(amount) = parse_bank_copper_amount(&input) else {
9476 self.state
9477 .push_log("Enter a copper amount (blank or 0 = full ledger).");
9478 return Ok(());
9479 };
9480 self.bank_withdraw(amount).await?;
9481 self.state.bank_ui_mode = BankUiMode::Menu;
9482 }
9483 BankUiMode::TransferName { input } => {
9484 let name = input.trim().to_string();
9485 if name.is_empty() {
9486 self.state.push_log("Enter the recipient character name.");
9487 return Ok(());
9488 }
9489 self.state.bank_ui_mode = BankUiMode::TransferAmount {
9490 to_name: name,
9491 input: String::new(),
9492 };
9493 }
9494 BankUiMode::TransferAmount { to_name, input } => {
9495 let amount: u64 = match input.trim().parse() {
9496 Ok(v) if v > 0 => v,
9497 _ => {
9498 self.state
9499 .push_log("Enter a positive copper amount to transfer.");
9500 return Ok(());
9501 }
9502 };
9503 self.bank_transfer(None, to_name, amount).await?;
9504 self.state.bank_ui_mode = BankUiMode::Menu;
9505 }
9506 }
9507 Ok(())
9508 }
9509
9510 pub fn bank_transfer_back(&mut self) {
9511 match &self.state.bank_ui_mode {
9512 BankUiMode::TransferAmount { to_name, .. } => {
9513 self.state.bank_ui_mode = BankUiMode::TransferName {
9514 input: to_name.clone(),
9515 };
9516 }
9517 BankUiMode::TransferName { .. }
9518 | BankUiMode::DepositAmount { .. }
9519 | BankUiMode::WithdrawAmount { .. } => {
9520 self.state.bank_ui_mode = BankUiMode::Menu;
9521 }
9522 BankUiMode::Menu => {}
9523 }
9524 }
9525
9526 pub fn bank_transfer_append_char(&mut self, c: char) {
9527 match &mut self.state.bank_ui_mode {
9528 BankUiMode::TransferName { input } => {
9529 if input.len() < 32 && !c.is_control() {
9530 input.push(c);
9531 }
9532 }
9533 BankUiMode::DepositAmount { input }
9534 | BankUiMode::WithdrawAmount { input }
9535 | BankUiMode::TransferAmount { input, .. } => {
9536 if c.is_ascii_digit() && input.len() < 12 {
9537 input.push(c);
9538 }
9539 }
9540 BankUiMode::Menu => {}
9541 }
9542 }
9543
9544 pub fn bank_transfer_backspace(&mut self) {
9545 match &mut self.state.bank_ui_mode {
9546 BankUiMode::TransferName { input }
9547 | BankUiMode::DepositAmount { input }
9548 | BankUiMode::WithdrawAmount { input }
9549 | BankUiMode::TransferAmount { input, .. } => {
9550 input.pop();
9551 }
9552 BankUiMode::Menu => {}
9553 }
9554 }
9555
9556 pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
9557 let npc_id = self.state.bank_panel.as_ref().map(|p| p.npc_id.clone());
9558 self.state.clear_bank_panel();
9559 if let Some(npc_id) = npc_id {
9560 self.seq += 1;
9561 self.session
9562 .submit_intent(Intent::BankClose {
9563 entity_id: self.state.entity_id,
9564 npc_id,
9565 seq: self.seq,
9566 })
9567 .await?;
9568 self.state.intents_sent += 1;
9569 }
9570 Ok(())
9571 }
9572
9573 pub async fn storage_store(
9574 &mut self,
9575 item_instance_id: uuid::Uuid,
9576 quantity: Option<u32>,
9577 ) -> anyhow::Result<()> {
9578 let Some(panel) = self.state.storage_panel.clone() else {
9579 return Ok(());
9580 };
9581 self.seq += 1;
9582 self.session
9583 .submit_intent(Intent::StorageStore {
9584 entity_id: self.state.entity_id,
9585 npc_id: panel.npc_id,
9586 item_instance_id,
9587 quantity,
9588 seq: self.seq,
9589 })
9590 .await?;
9591 self.state.intents_sent += 1;
9592 Ok(())
9593 }
9594
9595 pub async fn storage_take(
9596 &mut self,
9597 item_instance_id: uuid::Uuid,
9598 quantity: Option<u32>,
9599 ) -> anyhow::Result<()> {
9600 let Some(panel) = self.state.storage_panel.clone() else {
9601 return Ok(());
9602 };
9603 self.seq += 1;
9604 self.session
9605 .submit_intent(Intent::StorageTake {
9606 entity_id: self.state.entity_id,
9607 npc_id: panel.npc_id,
9608 item_instance_id,
9609 quantity,
9610 seq: self.seq,
9611 })
9612 .await?;
9613 self.state.intents_sent += 1;
9614 Ok(())
9615 }
9616
9617 pub async fn storage_ship(
9618 &mut self,
9619 dest_building_id: String,
9620 item_instance_id: uuid::Uuid,
9621 quantity: Option<u32>,
9622 ) -> anyhow::Result<()> {
9623 let Some(panel) = self.state.storage_panel.clone() else {
9624 return Ok(());
9625 };
9626 self.seq += 1;
9627 self.session
9628 .submit_intent(Intent::StorageShip {
9629 entity_id: self.state.entity_id,
9630 npc_id: panel.npc_id,
9631 dest_building_id,
9632 item_instance_id,
9633 quantity,
9634 seq: self.seq,
9635 })
9636 .await?;
9637 self.state.intents_sent += 1;
9638 Ok(())
9639 }
9640
9641 pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
9642 let npc_id = self.state.storage_panel.as_ref().map(|p| p.npc_id.clone());
9643 self.state.clear_storage_panel();
9644 if let Some(npc_id) = npc_id {
9645 self.seq += 1;
9646 self.session
9647 .submit_intent(Intent::StorageClose {
9648 entity_id: self.state.entity_id,
9649 npc_id,
9650 seq: self.seq,
9651 })
9652 .await?;
9653 self.state.intents_sent += 1;
9654 }
9655 Ok(())
9656 }
9657
9658 pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
9659 let npc_id = self.state.market_panel.as_ref().map(|p| p.npc_id.clone());
9660 self.state.clear_market_panel();
9661 if let Some(npc_id) = npc_id {
9662 self.seq += 1;
9663 self.session
9664 .submit_intent(Intent::MarketClose {
9665 entity_id: self.state.entity_id,
9666 npc_id,
9667 seq: self.seq,
9668 })
9669 .await?;
9670 self.state.intents_sent += 1;
9671 }
9672 Ok(())
9673 }
9674
9675 pub fn market_move_selection(&mut self, delta: i32) {
9676 let indices = self.state.market_filtered_listing_indices();
9677 let n = indices.len();
9678 if n == 0 {
9679 self.state.market_menu_index = 0;
9680 return;
9681 }
9682 let cur = self.state.market_menu_index as i32;
9683 self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
9684 }
9685
9686 pub fn market_page_selection(&mut self, pages: i32) {
9687 let indices = self.state.market_filtered_listing_indices();
9688 let n = indices.len();
9689 if n == 0 {
9690 self.state.market_menu_index = 0;
9691 return;
9692 }
9693 self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
9694 }
9695
9696 pub fn market_list_page(&mut self, pages: i32) {
9697 match &self.state.market_ui_mode {
9698 MarketUiMode::ListSource { index } => {
9699 let n = self.state.market_list_source_options().len();
9700 if n == 0 {
9701 return;
9702 }
9703 let next = page_list_index(*index, pages, n);
9704 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9705 }
9706 MarketUiMode::ListPricingMode { index, .. } => {
9707 let next = page_list_index(*index, pages, 2);
9708 if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
9709 {
9710 *index = next;
9711 }
9712 }
9713 MarketUiMode::ListPick { source, index } => {
9714 let opts = self.state.market_list_item_options(source);
9715 let n = opts.len();
9716 if n == 0 {
9717 return;
9718 }
9719 let next = page_list_index(*index, pages, n);
9720 self.state.market_ui_mode = MarketUiMode::ListPick {
9721 source: source.clone(),
9722 index: next,
9723 };
9724 }
9725 _ => {}
9726 }
9727 }
9728
9729 pub fn market_cycle_category(&mut self, delta: i32) {
9730 let groups = self.state.market_available_category_groups();
9731 let mut labels: Vec<Option<&'static str>> = vec![None];
9733 labels.extend(groups.into_iter().map(Some));
9734 let n = labels.len() as i32;
9735 let cur = labels
9736 .iter()
9737 .position(|g| *g == self.state.market_category_filter)
9738 .unwrap_or(0) as i32;
9739 let next = (cur + delta).rem_euclid(n) as usize;
9740 self.state.market_category_filter = labels[next];
9741 self.state.market_menu_index = 0;
9742 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9743 let source = source.clone();
9744 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9745 }
9746 }
9747
9748 pub fn focus_market_filter(&mut self) {
9749 self.state.market_filter_focused = true;
9750 }
9751
9752 pub fn append_market_filter_char(&mut self, ch: char) {
9753 if !self.state.market_filter_focused {
9754 return;
9755 }
9756 if ch.is_control() {
9757 return;
9758 }
9759 if self.state.market_filter.len() < 48 {
9760 self.state.market_filter.push(ch);
9761 self.state.market_menu_index = 0;
9762 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9763 let source = source.clone();
9764 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9765 }
9766 }
9767 }
9768
9769 pub fn market_filter_backspace(&mut self) {
9770 if !self.state.market_filter_focused {
9771 return;
9772 }
9773 self.state.market_filter.pop();
9774 self.state.market_menu_index = 0;
9775 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9776 let source = source.clone();
9777 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9778 }
9779 }
9780
9781 pub fn clear_or_blur_market_filter(&mut self) -> bool {
9783 if self.state.market_filter_focused {
9784 if !self.state.market_filter.is_empty() {
9785 self.state.market_filter.clear();
9786 self.state.market_menu_index = 0;
9787 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9788 let source = source.clone();
9789 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9790 }
9791 return true;
9792 }
9793 self.state.market_filter_focused = false;
9794 return true;
9795 }
9796 if !self.state.market_filter.is_empty() {
9797 self.state.market_filter.clear();
9798 self.state.market_menu_index = 0;
9799 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9800 let source = source.clone();
9801 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9802 }
9803 return true;
9804 }
9805 false
9806 }
9807
9808 pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
9809 if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
9810 return self.market_confirm_buy(listing_id, qty).await;
9811 }
9812 let Some(panel) = self.state.market_panel.clone() else {
9813 return Ok(());
9814 };
9815 let indices = self.state.market_filtered_listing_indices();
9816 let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
9817 return Ok(());
9818 };
9819 let Some(listing) = panel.listings.get(raw_idx) else {
9820 return Ok(());
9821 };
9822 if listing.mine {
9823 self.seq += 1;
9824 self.session
9825 .submit_intent(Intent::MarketDelist {
9826 entity_id: self.state.entity_id,
9827 npc_id: panel.npc_id.clone(),
9828 listing_id: listing.listing_id,
9829 dest: flatland_protocol::GoodsLocation::Person,
9830 seq: self.seq,
9831 })
9832 .await?;
9833 self.state.intents_sent += 1;
9834 return Ok(());
9835 }
9836 if listing.npc_price {
9837 self.state
9838 .push_log("NPC-price listings are bought by merchants only.");
9839 return Ok(());
9840 }
9841 let qty = 1u32.min(listing.quantity).max(1);
9842 let line = listing.unit_price_copper.saturating_mul(qty as u64);
9843 self.state.market_buy_confirm = Some((
9844 listing.listing_id,
9845 qty,
9846 listing.unit_price_copper,
9847 line,
9848 listing.display_name.clone(),
9849 ));
9850 Ok(())
9851 }
9852
9853 pub async fn market_confirm_buy(
9854 &mut self,
9855 listing_id: uuid::Uuid,
9856 quantity: u32,
9857 ) -> anyhow::Result<()> {
9858 let Some(panel) = self.state.market_panel.clone() else {
9859 self.state.market_buy_confirm = None;
9860 return Ok(());
9861 };
9862 self.state.market_buy_confirm = None;
9863 self.seq += 1;
9864 self.session
9865 .submit_intent(Intent::MarketBuy {
9866 entity_id: self.state.entity_id,
9867 npc_id: panel.npc_id,
9868 listing_id,
9869 quantity,
9870 dest: flatland_protocol::GoodsLocation::Person,
9871 seq: self.seq,
9872 })
9873 .await?;
9874 self.state.intents_sent += 1;
9875 Ok(())
9876 }
9877
9878 pub fn market_begin_list(&mut self) {
9880 if self.state.market_panel.is_none() {
9881 return;
9882 }
9883 let sources = self.state.market_list_source_options();
9884 if sources.is_empty() {
9885 self.state.push_log("Nothing to list from.");
9886 return;
9887 }
9888 if sources.len() == 1 {
9890 let (source, _) = sources[0].clone();
9891 let opts = self.state.market_list_item_options(&source);
9892 if opts.is_empty() {
9893 self.state.push_log("Nothing loose to list.");
9894 return;
9895 }
9896 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9897 self.state.market_buy_confirm = None;
9898 return;
9899 }
9900 self.state.market_buy_confirm = None;
9901 self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
9902 }
9903
9904 pub fn market_ui_back(&mut self) {
9905 self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
9906 MarketUiMode::Browse => MarketUiMode::Browse,
9907 MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
9908 MarketUiMode::ListPick { .. } => {
9909 if self.state.market_list_source_options().len() <= 1 {
9910 MarketUiMode::Browse
9911 } else {
9912 MarketUiMode::ListSource { index: 0 }
9913 }
9914 }
9915 MarketUiMode::ListAmount {
9916 source, pick_index, ..
9917 } => MarketUiMode::ListPick {
9918 source,
9919 index: pick_index,
9920 },
9921 MarketUiMode::ListPricingMode {
9922 source,
9923 item_instance_id,
9924 template_id,
9925 label,
9926 max_qty,
9927 quantity,
9928 pick_index,
9929 ..
9930 } => {
9931 let input = quantity.map(|q| q.to_string()).unwrap_or_default();
9932 MarketUiMode::ListAmount {
9933 source,
9934 pick_index,
9935 item_instance_id,
9936 template_id,
9937 label,
9938 max_qty,
9939 input,
9940 }
9941 }
9942 MarketUiMode::ListPrice {
9943 source,
9944 pick_index,
9945 item_instance_id,
9946 template_id,
9947 label,
9948 max_qty,
9949 quantity,
9950 ..
9951 } => MarketUiMode::ListPricingMode {
9952 source,
9953 pick_index,
9954 item_instance_id,
9955 template_id,
9956 label,
9957 quantity,
9958 max_qty,
9959 index: 1,
9960 },
9961 };
9962 }
9963
9964 pub fn market_list_move(&mut self, delta: i32) {
9965 match &self.state.market_ui_mode {
9966 MarketUiMode::ListSource { index } => {
9967 let n = self.state.market_list_source_options().len();
9968 if n == 0 {
9969 return;
9970 }
9971 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9972 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9973 }
9974 MarketUiMode::ListPricingMode { index, .. } => {
9975 let next = (*index as i32 + delta).rem_euclid(2) as usize;
9976 if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
9977 {
9978 *index = next;
9979 }
9980 }
9981 MarketUiMode::ListPick { source, index } => {
9982 let opts = self.state.market_list_item_options(source);
9983 let n = opts.len();
9984 if n == 0 {
9985 return;
9986 }
9987 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9988 self.state.market_ui_mode = MarketUiMode::ListPick {
9989 source: source.clone(),
9990 index: next,
9991 };
9992 }
9993 _ => {}
9994 }
9995 }
9996
9997 pub fn market_list_amount_append_char(&mut self, c: char) {
9998 if !c.is_ascii_digit() {
9999 return;
10000 }
10001 match &mut self.state.market_ui_mode {
10002 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
10003 if input.len() < 12 {
10004 input.push(c);
10005 }
10006 }
10007 _ => {}
10008 }
10009 }
10010
10011 pub fn market_list_amount_backspace(&mut self) {
10012 match &mut self.state.market_ui_mode {
10013 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
10014 input.pop();
10015 }
10016 _ => {}
10017 }
10018 }
10019
10020 pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
10021 match self.state.market_ui_mode.clone() {
10022 MarketUiMode::Browse => Ok(()),
10023 MarketUiMode::ListSource { index } => {
10024 let sources = self.state.market_list_source_options();
10025 let Some((source, _)) = sources.get(index).cloned() else {
10026 return Ok(());
10027 };
10028 let opts = self.state.market_list_item_options(&source);
10029 if opts.is_empty() {
10030 self.state.push_log("Nothing to list from that source.");
10031 return Ok(());
10032 }
10033 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10034 Ok(())
10035 }
10036 MarketUiMode::ListPick { source, index } => {
10037 let opts = self.state.market_list_item_options(&source);
10038 let Some(opt) = opts.get(index) else {
10039 self.state.push_log("Nothing to list.");
10040 self.state.market_ui_mode = MarketUiMode::Browse;
10041 return Ok(());
10042 };
10043 self.state.market_ui_mode = MarketUiMode::ListAmount {
10044 source,
10045 pick_index: index,
10046 item_instance_id: opt.item_instance_id,
10047 template_id: opt.template_id.clone(),
10048 label: opt.label.clone(),
10049 max_qty: opt.quantity.max(1),
10050 input: String::new(),
10051 };
10052 Ok(())
10053 }
10054 MarketUiMode::ListAmount {
10055 source,
10056 pick_index,
10057 item_instance_id,
10058 template_id,
10059 label,
10060 max_qty,
10061 input,
10062 ..
10063 } => {
10064 let Some(qty_opt) = parse_storage_quantity(&input) else {
10065 self.state.push_log("Enter a quantity (blank = all).");
10066 return Ok(());
10067 };
10068 if let Some(q) = qty_opt {
10069 if q > max_qty {
10070 self.state.push_log(format!("Only {max_qty} available."));
10071 return Ok(());
10072 }
10073 }
10074 self.state.market_ui_mode = MarketUiMode::ListPricingMode {
10075 source,
10076 pick_index,
10077 item_instance_id,
10078 template_id,
10079 label,
10080 quantity: qty_opt,
10081 max_qty,
10082 index: 0,
10083 };
10084 Ok(())
10085 }
10086 MarketUiMode::ListPricingMode {
10087 source,
10088 pick_index,
10089 item_instance_id,
10090 template_id,
10091 label,
10092 quantity,
10093 max_qty,
10094 index,
10095 } => {
10096 if index == 0 {
10097 if self
10098 .state
10099 .npc_market_dump_unit_estimate(&template_id)
10100 .is_none()
10101 {
10102 self.state
10103 .push_log("That item has no NPC value — use a fixed price instead.");
10104 return Ok(());
10105 }
10106 return self
10107 .submit_market_list_intent(
10108 source,
10109 item_instance_id,
10110 quantity,
10111 0,
10112 true,
10113 &label,
10114 )
10115 .await;
10116 }
10117 self.state.market_ui_mode = MarketUiMode::ListPrice {
10118 source,
10119 pick_index,
10120 item_instance_id,
10121 template_id,
10122 label,
10123 quantity,
10124 max_qty,
10125 input: String::new(),
10126 };
10127 Ok(())
10128 }
10129 MarketUiMode::ListPrice {
10130 source,
10131 item_instance_id,
10132 label,
10133 quantity,
10134 input,
10135 ..
10136 } => {
10137 let price = input.trim().parse::<u64>().unwrap_or(0);
10138 if price == 0 {
10139 self.state
10140 .push_log("Enter a unit price of at least 1 copper.");
10141 return Ok(());
10142 }
10143 self.submit_market_list_intent(
10144 source,
10145 item_instance_id,
10146 quantity,
10147 price,
10148 false,
10149 &label,
10150 )
10151 .await
10152 }
10153 }
10154 }
10155
10156 async fn submit_market_list_intent(
10157 &mut self,
10158 source: MarketListSourceKind,
10159 item_instance_id: uuid::Uuid,
10160 quantity: Option<u32>,
10161 unit_price_copper: u64,
10162 npc_price: bool,
10163 label: &str,
10164 ) -> anyhow::Result<()> {
10165 let Some(panel) = self.state.market_panel.clone() else {
10166 self.state.market_ui_mode = MarketUiMode::Browse;
10167 return Ok(());
10168 };
10169 let goods = match source {
10170 MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
10171 MarketListSourceKind::TownStorage { building_id } => {
10172 flatland_protocol::GoodsLocation::TownStorage { building_id }
10173 }
10174 };
10175 self.seq += 1;
10176 self.session
10177 .submit_intent(Intent::MarketList {
10178 entity_id: self.state.entity_id,
10179 npc_id: panel.npc_id,
10180 source: goods,
10181 item_instance_id,
10182 quantity,
10183 unit_price_copper,
10184 npc_price,
10185 seq: self.seq,
10186 })
10187 .await?;
10188 self.state.intents_sent += 1;
10189 if npc_price {
10190 self.state
10191 .push_log(format!("Listing {label} at NPC price…"));
10192 } else {
10193 self.state
10194 .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
10195 }
10196 self.state.market_ui_mode = MarketUiMode::Browse;
10197 Ok(())
10198 }
10199
10200 pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
10202 let return_to_verbs = self.state.npc_verb_target.is_some();
10203 self.close_shop_menu().await?;
10204 if return_to_verbs {
10205 self.state.show_npc_verb_menu = true;
10206 }
10207 Ok(())
10208 }
10209
10210 pub fn shop_tab_toggle(&mut self) {
10211 self.state.shop_tab = match self.state.shop_tab {
10212 ShopTab::Buy => ShopTab::Sell,
10213 ShopTab::Sell => ShopTab::Buy,
10214 };
10215 self.state.shop_menu_index = 0;
10216 if self.state.shop_tab == ShopTab::Sell {
10217 self.state.shop_quantity_set_max();
10218 }
10219 self.state.clamp_shop_selection();
10220 }
10221
10222 pub fn shop_menu_move(&mut self, delta: i32) {
10223 self.state.shop_menu_move(delta);
10224 }
10225
10226 pub fn shop_quantity_adjust(&mut self, delta: i32) {
10227 self.state.shop_quantity_adjust(delta);
10228 }
10229
10230 pub fn shop_quantity_set_max(&mut self) {
10231 self.state.shop_quantity_set_max();
10232 }
10233
10234 pub fn shop_quantity_set_min(&mut self) {
10235 self.state.shop_quantity_set_min();
10236 }
10237
10238 pub fn toggle_quest_menu(&mut self) {
10239 self.state.show_quest_menu = !self.state.show_quest_menu;
10240 if self.state.show_quest_menu {
10241 self.state.quest_menu_index = 0;
10242 self.state.quest_withdraw_confirm = false;
10243 self.state.show_workers_menu = false;
10244 }
10245 }
10246
10247 pub fn toggle_workers_menu(&mut self) {
10248 if self.state.show_workers_menu {
10249 self.close_workers_menu_ui();
10250 } else {
10251 self.state.show_workers_menu = true;
10252 self.state.workers_menu_index = 0;
10253 self.state.show_quest_menu = false;
10254 self.close_worker_give_picker();
10255 self.close_worker_give_target_picker();
10256 self.close_worker_take_picker();
10257 self.close_worker_teach_picker();
10258 self.cancel_worker_rename();
10259 }
10260 }
10261
10262 pub fn close_workers_menu_ui(&mut self) {
10264 self.state.show_workers_menu = false;
10265 self.cancel_worker_dismissal();
10266 self.close_worker_give_picker();
10267 self.close_worker_give_target_picker();
10268 self.close_worker_take_picker();
10269 self.close_worker_teach_picker();
10270 self.cancel_worker_rename();
10271 }
10272
10273 pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
10275 let Some(idx) = self
10276 .state
10277 .hired_workers
10278 .iter()
10279 .position(|w| w.instance_id == instance_id)
10280 else {
10281 anyhow::bail!("worker not found");
10282 };
10283 let label = self.state.hired_workers[idx].label.clone();
10284 self.state.show_workers_menu = true;
10285 self.state.workers_menu_index = idx;
10286 self.state.show_quest_menu = false;
10287 self.close_worker_give_picker();
10288 self.close_worker_give_target_picker();
10289 self.close_worker_take_picker();
10290 self.close_worker_teach_picker();
10291 self.cancel_worker_rename();
10292 self.set_worker_attending(instance_id, true).await?;
10293 self.state
10294 .push_log(format!("Managing {label} — job paused while menu is open"));
10295 Ok(())
10296 }
10297
10298 pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
10300 self.close_workers_menu_ui();
10301 self.release_worker_attend().await
10302 }
10303
10304 async fn set_worker_attending(
10305 &mut self,
10306 instance_id: &str,
10307 attending: bool,
10308 ) -> anyhow::Result<()> {
10309 if attending {
10310 if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
10311 return Ok(());
10312 }
10313 if let Some(prev) = self.state.attending_worker_instance_id.clone() {
10315 if prev != instance_id {
10316 self.send_attend_hired_worker(&prev, false).await?;
10317 }
10318 }
10319 self.send_attend_hired_worker(instance_id, true).await?;
10320 self.state.attending_worker_instance_id = Some(instance_id.to_string());
10321 } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
10322 self.send_attend_hired_worker(instance_id, false).await?;
10323 self.state.attending_worker_instance_id = None;
10324 }
10325 Ok(())
10326 }
10327
10328 pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
10329 let Some(id) = self.state.attending_worker_instance_id.take() else {
10330 return Ok(());
10331 };
10332 self.send_attend_hired_worker(&id, false).await
10333 }
10334
10335 async fn send_attend_hired_worker(
10336 &mut self,
10337 worker_instance_id: &str,
10338 attending: bool,
10339 ) -> anyhow::Result<()> {
10340 self.seq += 1;
10341 self.session
10342 .submit_intent(Intent::AttendHiredWorker {
10343 entity_id: self.state.entity_id,
10344 worker_instance_id: worker_instance_id.to_string(),
10345 attending,
10346 seq: self.seq,
10347 })
10348 .await?;
10349 self.state.intents_sent += 1;
10350 Ok(())
10351 }
10352
10353 pub fn workers_menu_move(&mut self, delta: i32) {
10354 let n = self.state.hired_workers.len();
10355 if n == 0 {
10356 return;
10357 }
10358 let idx = self.state.workers_menu_index as i32;
10359 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10360 }
10361
10362 pub fn toggle_workers_menu_compact(&mut self) {
10363 self.state.workers_menu_compact = !self.state.workers_menu_compact;
10364 let mut cfg = crate::client_config::ClientConfig::load();
10365 let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
10366 }
10367
10368 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
10369 let Some(worker) = self
10370 .state
10371 .hired_workers
10372 .get(self.state.workers_menu_index)
10373 .cloned()
10374 else {
10375 anyhow::bail!("no worker selected");
10376 };
10377 self.dismiss_worker_by_id(&worker.instance_id, &worker.label)
10378 .await
10379 }
10380
10381 pub fn request_worker_dismissal(&mut self) -> anyhow::Result<()> {
10383 let Some(worker) = self
10384 .state
10385 .hired_workers
10386 .get(self.state.workers_menu_index)
10387 .cloned()
10388 else {
10389 anyhow::bail!("no worker selected");
10390 };
10391 self.state.worker_dismiss_confirmation = Some(WorkerDismissConfirmation {
10392 worker_instance_id: worker.instance_id,
10393 worker_label: worker.label,
10394 });
10395 Ok(())
10396 }
10397
10398 pub fn cancel_worker_dismissal(&mut self) {
10399 self.state.worker_dismiss_confirmation = None;
10400 }
10401
10402 pub async fn confirm_worker_dismissal(&mut self) -> anyhow::Result<()> {
10403 let Some(confirm) = self.state.worker_dismiss_confirmation.clone() else {
10404 return Ok(());
10405 };
10406 self.dismiss_worker_by_id(&confirm.worker_instance_id, &confirm.worker_label)
10407 .await?;
10408 self.cancel_worker_dismissal();
10409 Ok(())
10410 }
10411
10412 async fn dismiss_worker_by_id(
10413 &mut self,
10414 worker_instance_id: &str,
10415 worker_label: &str,
10416 ) -> anyhow::Result<()> {
10417 self.seq += 1;
10418 self.session
10419 .submit_intent(Intent::DismissWorker {
10420 entity_id: self.state.entity_id,
10421 worker_instance_id: worker_instance_id.to_string(),
10422 seq: self.seq,
10423 })
10424 .await?;
10425 self.state.intents_sent += 1;
10426 self.state
10427 .hired_workers
10428 .retain(|w| w.instance_id != worker_instance_id);
10429 if self.state.workers_menu_index >= self.state.hired_workers.len() {
10430 self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
10431 }
10432 self.state.push_log(format!("Dismissed {worker_label}"));
10433 Ok(())
10434 }
10435
10436 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
10437 let Some(worker) = self
10438 .state
10439 .hired_workers
10440 .get(self.state.workers_menu_index)
10441 .cloned()
10442 else {
10443 anyhow::bail!("no worker selected");
10444 };
10445 let mode = match worker.mode {
10446 flatland_protocol::WorkerModeView::Companion => "defender",
10447 flatland_protocol::WorkerModeView::Defender => "job_loop",
10448 flatland_protocol::WorkerModeView::JobLoop => "idle",
10449 flatland_protocol::WorkerModeView::Idle => "companion",
10450 };
10451 self.seq += 1;
10452 self.session
10453 .submit_intent(Intent::SetWorkerMode {
10454 entity_id: self.state.entity_id,
10455 worker_instance_id: worker.instance_id,
10456 mode: mode.into(),
10457 seq: self.seq,
10458 })
10459 .await?;
10460 self.state.intents_sent += 1;
10461 Ok(())
10462 }
10463
10464 pub async fn workers_deliver_selected_to_storage(&mut self) -> anyhow::Result<()> {
10465 let Some(worker) = self
10466 .state
10467 .hired_workers
10468 .get(self.state.workers_menu_index)
10469 .cloned()
10470 else {
10471 anyhow::bail!("no worker selected");
10472 };
10473 if !matches!(worker.mode, flatland_protocol::WorkerModeView::Companion) {
10474 anyhow::bail!("switch the worker to companion mode first");
10475 }
10476 if worker.step_label.starts_with("delivering to ")
10477 || worker.step_label == "returning to you"
10478 {
10479 anyhow::bail!("worker is already delivering to storage");
10480 }
10481 self.seq += 1;
10482 self.session
10483 .submit_intent(Intent::DeliverWorkerToNearestStorage {
10484 entity_id: self.state.entity_id,
10485 worker_instance_id: worker.instance_id.clone(),
10486 seq: self.seq,
10487 })
10488 .await?;
10489 self.state.intents_sent += 1;
10490 self.state.push_log(format!(
10491 "{} is delivering carried items to storage",
10492 worker.label
10493 ));
10494 Ok(())
10495 }
10496
10497 pub async fn workers_cancel_delivery_selected(&mut self) -> anyhow::Result<()> {
10498 let Some(worker) = self
10499 .state
10500 .hired_workers
10501 .get(self.state.workers_menu_index)
10502 .cloned()
10503 else {
10504 anyhow::bail!("no worker selected");
10505 };
10506 if !(worker.step_label.starts_with("delivering to ")
10507 || worker.step_label == "returning to you")
10508 {
10509 anyhow::bail!("worker has no active delivery");
10510 }
10511 self.seq += 1;
10512 self.session
10513 .submit_intent(Intent::CancelWorkerDelivery {
10514 entity_id: self.state.entity_id,
10515 worker_instance_id: worker.instance_id,
10516 seq: self.seq,
10517 })
10518 .await?;
10519 self.state.intents_sent += 1;
10520 self.state
10521 .push_log(format!("Canceled delivery for {}", worker.label));
10522 Ok(())
10523 }
10524
10525 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
10526 if self.state.hired_workers.is_empty() {
10527 return self.hire_worker_laborer().await;
10528 }
10529 self.workers_toggle_mode_selected().await
10530 }
10531
10532 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
10535 let row = self
10536 .state
10537 .inventory_selected_row()
10538 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
10539 .clone();
10540 if row.from != flatland_protocol::InventoryLocation::Root {
10541 anyhow::bail!("select a carried item to give");
10542 }
10543 let Some(instance_id) = row.stack.item_instance_id else {
10544 anyhow::bail!("that stack can't be given");
10545 };
10546 let options = self.nearby_worker_give_targets();
10547 if options.is_empty() {
10548 anyhow::bail!(
10549 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
10550 );
10551 }
10552 let item_label = row
10553 .stack
10554 .display_name
10555 .as_deref()
10556 .unwrap_or(&row.stack.template_id)
10557 .to_string();
10558 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
10559 item_instance_id: instance_id,
10560 item_label,
10561 quantity: None,
10562 options,
10563 });
10564 self.state.worker_give_target_picker_index = 0;
10565 self.state.show_worker_give_target_picker = true;
10566 self.state.show_inventory_menu = false;
10568 Ok(())
10569 }
10570
10571 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
10573 let (px, py, _) = self.state.player_position_with_z();
10574 let mut options: Vec<WorkerGiveTargetOption> = self
10575 .state
10576 .hired_workers
10577 .iter()
10578 .filter_map(|w| {
10579 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
10580 if dist > WORKER_GIVE_RANGE_M {
10581 return None;
10582 }
10583 Some(WorkerGiveTargetOption {
10584 instance_id: w.instance_id.clone(),
10585 label: w.label.clone(),
10586 distance_m: dist,
10587 })
10588 })
10589 .collect();
10590 options.sort_by(|a, b| {
10591 a.distance_m
10592 .partial_cmp(&b.distance_m)
10593 .unwrap_or(std::cmp::Ordering::Equal)
10594 });
10595 options
10596 }
10597
10598 pub fn close_worker_give_target_picker(&mut self) {
10599 self.state.show_worker_give_target_picker = false;
10600 self.state.worker_give_target_picker = None;
10601 self.state.worker_give_target_picker_index = 0;
10602 }
10603
10604 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
10605 let Some(picker) = &self.state.worker_give_target_picker else {
10606 return;
10607 };
10608 let n = picker.options.len();
10609 if n == 0 {
10610 return;
10611 }
10612 let idx = self.state.worker_give_target_picker_index as i32;
10613 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10614 }
10615
10616 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
10617 let Some(picker) = self.state.worker_give_target_picker.clone() else {
10618 anyhow::bail!("give target picker not open");
10619 };
10620 let Some(opt) = picker
10621 .options
10622 .get(self.state.worker_give_target_picker_index)
10623 .cloned()
10624 else {
10625 anyhow::bail!("no worker selected");
10626 };
10627 let Some(worker) = self
10628 .state
10629 .hired_workers
10630 .iter()
10631 .find(|w| w.instance_id == opt.instance_id)
10632 .cloned()
10633 else {
10634 self.close_worker_give_target_picker();
10635 anyhow::bail!("worker no longer hired");
10636 };
10637 self.give_item_to_worker(
10638 &worker.instance_id,
10639 &worker.label,
10640 worker.x,
10641 worker.y,
10642 picker.item_instance_id,
10643 &picker.item_label,
10644 picker.quantity,
10645 )
10646 .await?;
10647 self.close_worker_give_target_picker();
10648 Ok(())
10649 }
10650
10651 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
10653 self.open_worker_give_target_picker()
10654 }
10655
10656 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
10658 let Some(worker) = self
10659 .state
10660 .hired_workers
10661 .get(self.state.workers_menu_index)
10662 .cloned()
10663 else {
10664 anyhow::bail!("select a hired worker first");
10665 };
10666 let (px, py, _) = self.state.player_position_with_z();
10667 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10668 if dist > WORKER_GIVE_RANGE_M {
10669 anyhow::bail!(
10670 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
10671 worker.label
10672 );
10673 }
10674 let options = self.state.giveable_inventory_options();
10675 if options.is_empty() {
10676 anyhow::bail!("nothing in inventory to give");
10677 }
10678 self.state.worker_give_picker = Some(WorkerGivePicker {
10679 worker_instance_id: worker.instance_id,
10680 worker_label: worker.label,
10681 options,
10682 });
10683 self.state.worker_give_picker_index = 0;
10684 self.state.show_worker_give_picker = true;
10685 Ok(())
10686 }
10687
10688 pub fn close_worker_give_picker(&mut self) {
10689 self.state.show_worker_give_picker = false;
10690 self.state.worker_give_picker = None;
10691 self.state.worker_give_picker_index = 0;
10692 }
10693
10694 pub fn worker_give_picker_move(&mut self, delta: i32) {
10695 let Some(picker) = &self.state.worker_give_picker else {
10696 return;
10697 };
10698 let n = picker.options.len();
10699 if n == 0 {
10700 return;
10701 }
10702 let idx = self.state.worker_give_picker_index as i32;
10703 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10704 }
10705
10706 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
10708 let Some(picker) = self.state.worker_give_picker.clone() else {
10709 anyhow::bail!("give picker not open");
10710 };
10711 let Some(opt) = picker
10712 .options
10713 .get(self.state.worker_give_picker_index)
10714 .cloned()
10715 else {
10716 anyhow::bail!("no item selected");
10717 };
10718 let Some(worker) = self
10719 .state
10720 .hired_workers
10721 .iter()
10722 .find(|w| w.instance_id == picker.worker_instance_id)
10723 .cloned()
10724 else {
10725 self.close_worker_give_picker();
10726 anyhow::bail!("worker no longer hired");
10727 };
10728 self.give_item_to_worker(
10729 &worker.instance_id,
10730 &worker.label,
10731 worker.x,
10732 worker.y,
10733 opt.item_instance_id,
10734 &opt.label,
10735 None,
10736 )
10737 .await?;
10738 let options = self.state.giveable_inventory_options();
10740 if options.is_empty() {
10741 self.close_worker_give_picker();
10742 } else {
10743 self.state.worker_give_picker = Some(WorkerGivePicker {
10744 worker_instance_id: picker.worker_instance_id,
10745 worker_label: picker.worker_label,
10746 options,
10747 });
10748 if self.state.worker_give_picker_index
10749 >= self
10750 .state
10751 .worker_give_picker
10752 .as_ref()
10753 .map(|p| p.options.len())
10754 .unwrap_or(0)
10755 {
10756 self.state.worker_give_picker_index = self
10757 .state
10758 .worker_give_picker
10759 .as_ref()
10760 .map(|p| p.options.len().saturating_sub(1))
10761 .unwrap_or(0);
10762 }
10763 }
10764 Ok(())
10765 }
10766
10767 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10769 let Some(worker) = self
10770 .state
10771 .hired_workers
10772 .get(self.state.workers_menu_index)
10773 .cloned()
10774 else {
10775 anyhow::bail!("select a hired worker first");
10776 };
10777 let (px, py, _) = self.state.player_position_with_z();
10778 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10779 if dist > WORKER_GIVE_RANGE_M {
10780 anyhow::bail!(
10781 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
10782 worker.label
10783 );
10784 }
10785 let options = self.state.teachable_blueprint_options(&worker);
10786 if options.is_empty() {
10787 anyhow::bail!("no recipes you know that {} still needs", worker.label);
10788 }
10789 self.state.worker_teach_picker = Some(WorkerTeachPicker {
10790 worker_instance_id: worker.instance_id,
10791 worker_label: worker.label,
10792 worker_level: worker.level,
10793 options,
10794 });
10795 self.state.worker_teach_picker_index = 0;
10796 self.state.show_worker_teach_picker = true;
10797 Ok(())
10798 }
10799
10800 pub fn close_worker_teach_picker(&mut self) {
10801 self.state.show_worker_teach_picker = false;
10802 self.state.worker_teach_picker = None;
10803 self.state.worker_teach_picker_index = 0;
10804 }
10805
10806 pub fn worker_teach_picker_move(&mut self, delta: i32) {
10807 let Some(picker) = &self.state.worker_teach_picker else {
10808 return;
10809 };
10810 let n = picker.options.len();
10811 if n == 0 {
10812 return;
10813 }
10814 let idx = self.state.worker_teach_picker_index as i32;
10815 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10816 }
10817
10818 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10819 let Some(picker) = self.state.worker_teach_picker.clone() else {
10820 anyhow::bail!("teach picker not open");
10821 };
10822 let Some(opt) = picker
10823 .options
10824 .get(self.state.worker_teach_picker_index)
10825 .cloned()
10826 else {
10827 anyhow::bail!("nothing selected");
10828 };
10829 if !opt.level_ok {
10830 anyhow::bail!(
10831 "{} needs level {} (is level {})",
10832 picker.worker_label,
10833 opt.min_level,
10834 opt.worker_level
10835 );
10836 }
10837 if !opt.can_afford {
10838 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
10839 }
10840 let Some(worker) = self
10841 .state
10842 .hired_workers
10843 .iter()
10844 .find(|w| w.instance_id == picker.worker_instance_id)
10845 .cloned()
10846 else {
10847 anyhow::bail!("worker gone");
10848 };
10849 let (px, py, _) = self.state.player_position_with_z();
10850 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10851 if dist > WORKER_GIVE_RANGE_M {
10852 anyhow::bail!("worker {} too far — stand next to them", worker.label);
10853 }
10854 self.seq += 1;
10855 self.session
10856 .submit_intent(Intent::TeachWorkerBlueprint {
10857 entity_id: self.state.entity_id,
10858 worker_instance_id: picker.worker_instance_id.clone(),
10859 blueprint_id: opt.blueprint_id.clone(),
10860 seq: self.seq,
10861 })
10862 .await?;
10863 self.state.intents_sent += 1;
10864 self.state.push_log(format!(
10865 "Teaching {} to {} ({} cp)",
10866 opt.label, picker.worker_label, opt.cost_copper
10867 ));
10868 self.close_worker_teach_picker();
10869 Ok(())
10870 }
10871
10872 async fn give_item_to_worker(
10873 &mut self,
10874 worker_instance_id: &str,
10875 worker_label: &str,
10876 worker_x: f32,
10877 worker_y: f32,
10878 item_instance_id: uuid::Uuid,
10879 item_label: &str,
10880 quantity: Option<u32>,
10881 ) -> anyhow::Result<()> {
10882 let (px, py, _) = self.state.player_position_with_z();
10883 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
10884 if dist > WORKER_GIVE_RANGE_M {
10885 anyhow::bail!("worker {worker_label} too far — stand next to them");
10886 }
10887 self.seq += 1;
10888 self.session
10889 .submit_intent(Intent::GiveWorkerItem {
10890 entity_id: self.state.entity_id,
10891 worker_instance_id: worker_instance_id.to_string(),
10892 item_instance_id,
10893 quantity,
10894 seq: self.seq,
10895 })
10896 .await?;
10897 self.state.intents_sent += 1;
10898 self.state
10899 .remove_carried_instance(item_instance_id, quantity);
10900 self.state
10901 .push_log(format!("Gave {item_label} to {worker_label}"));
10902 Ok(())
10903 }
10904
10905 pub async fn equip_item_on_worker(
10909 &mut self,
10910 worker_instance_id: &str,
10911 item_instance_id: uuid::Uuid,
10912 slot: &str,
10913 ) -> anyhow::Result<()> {
10914 let Some(worker) = self
10915 .state
10916 .hired_workers
10917 .iter()
10918 .find(|worker| worker.instance_id == worker_instance_id)
10919 .cloned()
10920 else {
10921 anyhow::bail!("worker not found");
10922 };
10923 let (px, py, _) = self.state.player_position_with_z();
10924 if (worker.x - px).hypot(worker.y - py) > WORKER_GIVE_RANGE_M {
10925 anyhow::bail!("worker {} too far — stand next to them", worker.label);
10926 }
10927 self.seq += 1;
10928 self.session
10929 .submit_intent(Intent::EquipWorkerItem {
10930 entity_id: self.state.entity_id,
10931 worker_instance_id: worker.instance_id.clone(),
10932 item_instance_id,
10933 slot: slot.to_string(),
10934 seq: self.seq,
10935 })
10936 .await?;
10937 self.state.intents_sent += 1;
10938 self.state
10939 .push_log(format!("Equipped {slot} on {}", worker.label));
10940 Ok(())
10941 }
10942
10943 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
10945 let Some(worker) = self
10946 .state
10947 .hired_workers
10948 .get(self.state.workers_menu_index)
10949 .cloned()
10950 else {
10951 anyhow::bail!("select a hired worker first");
10952 };
10953 let (px, py, _) = self.state.player_position_with_z();
10954 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10955 if dist > WORKER_GIVE_RANGE_M {
10956 anyhow::bail!(
10957 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
10958 worker.label
10959 );
10960 }
10961 let options = Self::worker_inventory_options(&worker);
10962 if options.is_empty() {
10963 anyhow::bail!("{} isn't carrying anything", worker.label);
10964 }
10965 let initial_qty = options
10966 .first()
10967 .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
10968 .unwrap_or(1);
10969 self.state.worker_take_picker = Some(WorkerTakePicker {
10970 worker_instance_id: worker.instance_id,
10971 worker_label: worker.label,
10972 options,
10973 quantity: initial_qty,
10974 });
10975 self.state.worker_take_picker_index = 0;
10976 self.state.show_worker_take_picker = true;
10977 Ok(())
10978 }
10979
10980 fn worker_inventory_options(
10981 worker: &flatland_protocol::HiredWorkerView,
10982 ) -> Vec<WorkerGiveOption> {
10983 worker
10984 .inventory
10985 .iter()
10986 .filter_map(|stack| {
10987 let item_instance_id = stack.item_instance_id?;
10988 let label = stack
10989 .display_name
10990 .clone()
10991 .unwrap_or_else(|| stack.template_id.clone());
10992 let label = if stack.quantity > 1 {
10993 format!("{label} ×{}", stack.quantity)
10994 } else {
10995 label
10996 };
10997 Some(WorkerGiveOption {
10998 item_instance_id,
10999 label,
11000 quantity: stack.quantity,
11001 template_id: stack.template_id.clone(),
11002 })
11003 })
11004 .collect()
11005 }
11006
11007 pub fn close_worker_take_picker(&mut self) {
11008 self.state.show_worker_take_picker = false;
11009 self.state.worker_take_picker = None;
11010 self.state.worker_take_picker_index = 0;
11011 }
11012
11013 pub fn worker_take_picker_move(&mut self, delta: i32) {
11014 let Some(picker) = &self.state.worker_take_picker else {
11015 return;
11016 };
11017 let n = picker.options.len();
11018 if n == 0 {
11019 return;
11020 }
11021 let idx = self.state.worker_take_picker_index as i32;
11022 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11023 self.clamp_worker_take_quantity();
11024 }
11025
11026 pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
11027 let Some(picker) = &mut self.state.worker_take_picker else {
11028 return;
11029 };
11030 let max = picker
11031 .options
11032 .get(self.state.worker_take_picker_index)
11033 .map(|o| o.quantity.max(1))
11034 .unwrap_or(1);
11035 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
11036 picker.quantity = next as u32;
11037 }
11038
11039 pub fn worker_take_picker_set_quantity_max(&mut self) {
11040 let Some(picker) = &mut self.state.worker_take_picker else {
11041 return;
11042 };
11043 let max = picker
11044 .options
11045 .get(self.state.worker_take_picker_index)
11046 .map(|o| o.quantity.max(1))
11047 .unwrap_or(1);
11048 picker.quantity = max;
11049 }
11050
11051 pub fn worker_take_picker_set_quantity_min(&mut self) {
11052 let Some(picker) = &mut self.state.worker_take_picker else {
11053 return;
11054 };
11055 picker.quantity = 1;
11056 self.clamp_worker_take_quantity();
11057 }
11058
11059 fn clamp_worker_take_quantity(&mut self) {
11060 let Some(picker) = &mut self.state.worker_take_picker else {
11061 return;
11062 };
11063 let max = picker
11064 .options
11065 .get(self.state.worker_take_picker_index)
11066 .map(|o| o.quantity.max(1))
11067 .unwrap_or(1);
11068 if picker.quantity == 0 || picker.quantity > max {
11069 picker.quantity = if max > 1 { 1 } else { max };
11070 }
11071 }
11072
11073 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
11074 let Some(picker) = self.state.worker_take_picker.clone() else {
11075 anyhow::bail!("take picker not open");
11076 };
11077 let Some(opt) = picker
11078 .options
11079 .get(self.state.worker_take_picker_index)
11080 .cloned()
11081 else {
11082 anyhow::bail!("no item selected");
11083 };
11084 let Some(worker) = self
11085 .state
11086 .hired_workers
11087 .iter()
11088 .find(|w| w.instance_id == picker.worker_instance_id)
11089 .cloned()
11090 else {
11091 self.close_worker_take_picker();
11092 anyhow::bail!("worker no longer hired");
11093 };
11094 let qty = picker.quantity.clamp(1, opt.quantity.max(1));
11095 let intent_qty = if qty >= opt.quantity { None } else { Some(qty) };
11096 self.take_item_from_worker(
11097 &worker.instance_id,
11098 &worker.label,
11099 worker.x,
11100 worker.y,
11101 opt.item_instance_id,
11102 &opt.label,
11103 intent_qty,
11104 )
11105 .await?;
11106 Ok(())
11109 }
11110
11111 async fn take_item_from_worker(
11112 &mut self,
11113 worker_instance_id: &str,
11114 worker_label: &str,
11115 worker_x: f32,
11116 worker_y: f32,
11117 item_instance_id: uuid::Uuid,
11118 item_label: &str,
11119 quantity: Option<u32>,
11120 ) -> anyhow::Result<()> {
11121 let (px, py, _) = self.state.player_position_with_z();
11122 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
11123 if dist > WORKER_GIVE_RANGE_M {
11124 anyhow::bail!("worker {worker_label} too far — stand next to them");
11125 }
11126 self.seq += 1;
11127 self.session
11128 .submit_intent(Intent::TakeWorkerItem {
11129 entity_id: self.state.entity_id,
11130 worker_instance_id: worker_instance_id.to_string(),
11131 item_instance_id,
11132 quantity,
11133 seq: self.seq,
11134 })
11135 .await?;
11136 self.state.intents_sent += 1;
11137 let qty_note = quantity.map(|q| format!(" ×{q}")).unwrap_or_default();
11138 self.state.push_log(format!(
11139 "Taking {item_label}{qty_note} from {worker_label}…"
11140 ));
11141 Ok(())
11142 }
11143
11144 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
11145 if let Some(since) = self.state.pending_worker_hire_since {
11146 if since.elapsed() < WORKER_HIRE_PENDING_TIMEOUT {
11147 anyhow::bail!("hire request still pending — wait for the worker roster update");
11148 }
11149 self.state.pending_worker_hire_since = None;
11150 }
11151 if !self.state.has_worker_lodging() {
11152 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
11153 }
11154 self.seq += 1;
11155 self.session
11156 .submit_intent(Intent::HireWorker {
11157 entity_id: self.state.entity_id,
11158 def_id: "worker_laborer".into(),
11159 wage_copper_per_interval: 8,
11160 lodging_container_id: None,
11161 job_yaml: None,
11162 seq: self.seq,
11163 })
11164 .await?;
11165 self.state.intents_sent += 1;
11166 self.state.pending_worker_hire_since = Some(Instant::now());
11167 Ok(())
11168 }
11169
11170 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
11171 let Some(worker) = self
11172 .state
11173 .hired_workers
11174 .get(self.state.workers_menu_index)
11175 .cloned()
11176 else {
11177 anyhow::bail!("select a hired worker first");
11178 };
11179 let lodging = worker.lodging_container_id.clone().or_else(|| {
11180 crate::worker_route_editor::owned_lodging_container_ids(
11181 &self.state.placed_containers,
11182 self.state.character_id,
11183 )
11184 .into_iter()
11185 .next()
11186 .map(|(id, _)| id)
11187 });
11188 let label = worker.label.clone();
11189 let editor = if let Some(route) = &worker.route {
11190 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
11191 worker.instance_id,
11192 worker.label,
11193 route,
11194 lodging,
11195 )
11196 } else {
11197 crate::worker_route_editor::WorkerRouteEditorState::new(
11198 worker.instance_id,
11199 worker.label,
11200 lodging,
11201 )
11202 };
11203 self.state.worker_route_editor = Some(editor);
11204 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11205 if let Some(collapsed) =
11206 crate::client_config::ClientConfig::load().worker_route_panel_collapsed
11207 {
11208 ed.panel_collapsed = collapsed;
11209 }
11210 }
11211 self.state.show_workers_menu = false;
11212 self.state.push_log(format!(
11213 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
11214 ));
11215 Ok(())
11216 }
11217
11218 pub fn close_worker_route_editor(&mut self) {
11219 self.state.worker_route_editor = None;
11220 }
11221
11222 pub fn worker_route_editor_toggle_panel(&mut self) {
11223 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11224 ed.toggle_panel_collapsed();
11225 let collapsed = ed.panel_collapsed;
11226 let mut cfg = crate::client_config::ClientConfig::load();
11227 let _ = cfg.save_worker_route_panel_collapsed(collapsed);
11228 }
11229 }
11230
11231 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
11232 let n = {
11233 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11234 return;
11235 };
11236 ed.append_waypoint(x, y, z);
11237 ed.stop_count()
11238 };
11239 self.state
11240 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
11241 }
11242
11243 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
11246 let (px, py, _) = self.state.player_position_with_z();
11247 let inside = self.state.effective_inside_building();
11248 crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
11249 &self.state.placed_containers,
11250 &self.state.buildings,
11251 self.state.character_id,
11252 px,
11253 py,
11254 &self.state.hired_workers,
11255 inside.as_deref(),
11256 )
11257 }
11258
11259 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
11260 self.state.route_editor_node_candidates()
11261 }
11262
11263 fn re_open_harvest_picker(&mut self, index: usize, picked: std::collections::BTreeSet<String>) {
11264 use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
11265 let nodes = self.state.route_editor_node_candidates();
11266 let index = if nodes.is_empty() {
11267 ROUTE_PICKER_DONE_ROW
11268 } else {
11269 index.max(1).min(nodes.len())
11270 };
11271 self.re_open_sheet(S::HarvestPicker {
11272 index,
11273 picked,
11274 nodes,
11275 });
11276 }
11277
11278 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
11279 let (px, py, _) = self.state.player_position_with_z();
11280 crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
11281 }
11282
11283 fn re_template_candidates(&self) -> Vec<String> {
11284 let mut extra = Vec::new();
11285 if let Some(ed) = self.state.worker_route_editor.as_ref() {
11286 for stop in &ed.stops {
11287 match stop {
11288 crate::worker_route_editor::WorkerRouteStop::DepositAt {
11289 filter: Some(filter),
11290 ..
11291 } => extra.extend(filter.iter().cloned()),
11292 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
11293 extra.push(template.clone());
11294 }
11295 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
11296 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint)
11297 {
11298 extra.push(bp.output.clone());
11299 for input in &bp.inputs {
11300 extra.push(input.template_id.clone());
11301 }
11302 }
11303 }
11304 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
11305 for it in items {
11306 extra.push(it.template.clone());
11307 }
11308 }
11309 _ => {}
11310 }
11311 }
11312 if let Some(worker) = self
11314 .state
11315 .hired_workers
11316 .iter()
11317 .find(|w| w.instance_id == ed.worker_instance_id)
11318 {
11319 for recipe in &worker.known_blueprint_ids {
11320 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
11321 extra.push(bp.output.clone());
11322 }
11323 }
11324 }
11325 }
11326 crate::worker_route_editor::route_item_template_candidates(
11327 &self.state.placed_containers,
11328 self.state.character_id,
11329 &self.state.inventory,
11330 &self.state.blueprints,
11331 &self.state.resource_nodes,
11332 &extra,
11333 )
11334 }
11335
11336 fn re_blueprint_ids(&self) -> Vec<String> {
11337 let worker_known: Option<&[String]> = self
11338 .state
11339 .worker_route_editor
11340 .as_ref()
11341 .and_then(|ed| {
11342 self.state
11343 .hired_workers
11344 .iter()
11345 .find(|w| w.instance_id == ed.worker_instance_id)
11346 })
11347 .map(|w| w.known_blueprint_ids.as_slice());
11348 crate::worker_route_editor::worker_craft_blueprint_ids(&self.state.blueprints, worker_known)
11349 }
11350
11351 fn re_bed_candidates(&self) -> Vec<(String, String)> {
11352 crate::worker_route_editor::owned_lodging_container_ids(
11353 &self.state.placed_containers,
11354 self.state.character_id,
11355 )
11356 }
11357
11358 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
11359 self.state
11360 .placed_containers
11361 .iter()
11362 .find(|c| c.id == container_id)
11363 .map(|c| c.contents.clone())
11364 .unwrap_or_default()
11365 }
11366
11367 fn re_sheet_supports_filter(&self) -> bool {
11370 use crate::worker_route_editor::RouteEditorSheet as S;
11371 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
11372 matches!(
11373 ed.sheet,
11374 S::HarvestPicker { .. }
11375 | S::SellItem { .. }
11376 | S::DepositFilter { .. }
11377 | S::WithdrawItems { .. }
11378 | S::WithdrawContainers { .. }
11379 | S::DepositContainers { .. }
11380 | S::SellNpcs { .. }
11381 | S::CraftBlueprint { .. }
11382 | S::BedPicker { .. }
11383 )
11384 })
11385 }
11386
11387 pub fn re_sheet_row_visible(&self, row: usize) -> bool {
11389 use crate::worker_route_editor::{
11390 harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
11391 ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
11392 };
11393 let Some(ed) = self.state.worker_route_editor.as_ref() else {
11394 return false;
11395 };
11396 let filter = &ed.sheet_filter;
11397 match &ed.sheet {
11398 S::HarvestPicker { nodes, .. } => harvest_picker_row_matches(nodes, row, filter),
11399 S::SellItem { templates, .. } => {
11400 if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
11401 return true;
11402 }
11403 let slot = row.saturating_sub(2);
11404 templates.get(slot).is_some_and(|t| {
11405 let label = self.state.template_display_name(t);
11406 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
11407 })
11408 }
11409 S::DepositFilter { rows, .. } => {
11410 if row >= rows.len() {
11411 return true;
11412 }
11413 rows.get(row).is_some_and(|(t, _)| {
11414 let label = self.state.template_display_name(t);
11415 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
11416 })
11417 }
11418 S::WithdrawItems { lines, .. } => {
11419 if row >= lines.len() {
11420 return true;
11421 }
11422 lines.get(row).is_some_and(|l| {
11423 let label = self.state.template_display_name(&l.template);
11424 list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
11425 })
11426 }
11427 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
11428 self.re_container_candidates().get(row).is_some_and(|c| {
11429 list_filter_row_matches(
11430 filter,
11431 Some(c.dist),
11432 &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
11433 )
11434 })
11435 }
11436 S::SellNpcs { .. } => {
11437 if row == 0 {
11438 return true;
11439 }
11440 self.re_npc_candidates().get(row - 1).is_some_and(|n| {
11441 list_filter_row_matches(
11442 filter,
11443 Some(n.dist),
11444 &[n.label.as_str(), n.id.as_str()],
11445 )
11446 })
11447 }
11448 S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
11449 let label = self
11450 .state
11451 .blueprints
11452 .iter()
11453 .find(|b| &b.id == id)
11454 .map(|b| {
11455 if b.label.is_empty() {
11456 id.as_str()
11457 } else {
11458 b.label.as_str()
11459 }
11460 })
11461 .unwrap_or(id.as_str());
11462 list_filter_row_matches(filter, None, &[id.as_str(), label])
11463 }),
11464 S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
11465 list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
11466 }),
11467 _ => true,
11468 }
11469 }
11470
11471 fn re_sheet_clamp_index(&mut self) {
11472 let count = self.re_sheet_row_count();
11473 if count == 0 {
11474 return;
11475 }
11476 let cur = self.re_sheet_index();
11477 if self.re_sheet_row_visible(cur) {
11478 return;
11479 }
11480 for offset in 1..count {
11481 if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
11482 self.re_sheet_set_index(cur + offset);
11483 return;
11484 }
11485 if cur >= offset && self.re_sheet_row_visible(cur - offset) {
11486 self.re_sheet_set_index(cur - offset);
11487 return;
11488 }
11489 }
11490 }
11491
11492 fn re_sheet_set_index(&mut self, index: usize) {
11493 use crate::worker_route_editor::RouteEditorSheet as S;
11494 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11495 return;
11496 };
11497 match &mut ed.sheet {
11498 S::AddMenu { index: slot }
11499 | S::WaypointMenu { index: slot }
11500 | S::HarvestPicker { index: slot, .. }
11501 | S::WithdrawContainers { index: slot }
11502 | S::DepositContainers { index: slot }
11503 | S::SellNpcs { index: slot }
11504 | S::CraftBlueprint { index: slot }
11505 | S::BedPicker { index: slot }
11506 | S::FarmPlotPicker { index: slot, .. }
11507 | S::FarmPlantSeed { index: slot, .. }
11508 | S::WithdrawItems { index: slot, .. }
11509 | S::DepositFilter { index: slot, .. }
11510 | S::SellItem { index: slot, .. } => *slot = index,
11511 _ => {}
11512 }
11513 }
11514
11515 pub fn re_focus_sheet_filter(&mut self) {
11516 if !self.re_sheet_supports_filter() {
11517 return;
11518 }
11519 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11520 ed.sheet_filter_focused = true;
11521 }
11522 }
11523
11524 pub fn re_blur_sheet_filter_keep_text(&mut self) {
11525 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11526 return;
11527 };
11528 if !ed.sheet_filter_focused {
11529 return;
11530 }
11531 ed.sheet_filter_focused = false;
11532 self.re_sheet_clamp_index();
11533 }
11534
11535 pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
11536 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11537 return false;
11538 };
11539 if ed.sheet_filter_focused {
11540 ed.sheet_filter_focused = false;
11541 self.re_sheet_clamp_index();
11542 return true;
11543 }
11544 if !ed.sheet_filter.is_empty() {
11545 ed.sheet_filter.clear();
11546 self.re_sheet_clamp_index();
11547 return true;
11548 }
11549 false
11550 }
11551
11552 pub fn re_append_sheet_filter_char(&mut self, ch: char) {
11553 if ch.is_control() {
11554 return;
11555 }
11556 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11557 return;
11558 };
11559 if !ed.sheet_filter_focused {
11560 return;
11561 }
11562 ed.sheet_filter.push(ch);
11563 self.re_sheet_set_index(0);
11564 self.re_sheet_clamp_index();
11565 }
11566
11567 pub fn re_sheet_filter_backspace(&mut self) {
11568 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11569 return;
11570 };
11571 if !ed.sheet_filter_focused {
11572 return;
11573 }
11574 ed.sheet_filter.pop();
11575 self.re_sheet_set_index(0);
11576 self.re_sheet_clamp_index();
11577 }
11578
11579 pub fn re_sheet_row_count(&self) -> usize {
11581 use crate::worker_route_editor::{
11582 harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
11583 };
11584 let Some(ed) = self.state.worker_route_editor.as_ref() else {
11585 return 0;
11586 };
11587 match &ed.sheet {
11588 S::Stops => ed.stops.len(),
11589 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
11590 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
11591 S::WaypointMapPick => 0,
11592 S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
11593 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
11594 self.re_container_candidates().len()
11595 }
11596 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()),
11600 S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
11601 S::WaitEntry { .. } => 1,
11602 S::BedPicker { .. } => self.re_bed_candidates().len(),
11603 S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
11604 S::FarmPlantSeed { seeds, .. } => seeds.len(),
11605 }
11606 }
11607
11608 pub fn re_sheet_index(&self) -> usize {
11610 use crate::worker_route_editor::RouteEditorSheet as S;
11611 let Some(ed) = self.state.worker_route_editor.as_ref() else {
11612 return 0;
11613 };
11614 match &ed.sheet {
11615 S::AddMenu { index }
11616 | S::WaypointMenu { index }
11617 | S::HarvestPicker { index, .. }
11618 | S::WithdrawContainers { index }
11619 | S::DepositContainers { index }
11620 | S::SellNpcs { index }
11621 | S::CraftBlueprint { index }
11622 | S::BedPicker { index }
11623 | S::FarmPlotPicker { index, .. }
11624 | S::FarmPlantSeed { index, .. }
11625 | S::WithdrawItems { index, .. }
11626 | S::DepositFilter { index, .. }
11627 | S::SellItem { index, .. } => *index,
11628 _ => 0,
11629 }
11630 }
11631
11632 pub fn re_sheet_move(&mut self, delta: i32) {
11634 let count = self.re_sheet_row_count();
11635 if count == 0 {
11636 return;
11637 }
11638 let cur = self.re_sheet_index();
11639 let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
11640 self.re_sheet_set_index(next);
11641 }
11642
11643 pub fn re_sheet_page(&mut self, pages: i32) {
11644 let count = self.re_sheet_row_count();
11645 if count == 0 {
11646 return;
11647 }
11648 let cur = self.re_sheet_index();
11649 let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
11650 self.re_sheet_set_index(next);
11651 }
11652
11653 pub fn re_sheet_adjust(&mut self, delta: i32) {
11655 use crate::worker_route_editor::RouteEditorSheet as S;
11656 let index = self.re_sheet_index();
11657 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11658 return;
11659 };
11660 match &mut ed.sheet {
11661 S::WithdrawItems { lines, .. } => {
11662 if let Some(line) = lines.get_mut(index) {
11663 line.adjust_qty(delta);
11664 }
11665 }
11666 S::WaitEntry { ticks } => {
11667 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
11668 }
11669 _ => {}
11670 }
11671 }
11672
11673 pub fn re_sheet_back(&mut self) {
11674 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11675 return;
11676 };
11677 use crate::worker_route_editor::RouteEditorSheet as S;
11678 let was_editing = ed.editing_index.is_some();
11679 let from_top_picker = matches!(
11680 ed.sheet,
11681 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
11682 );
11683 ed.sheet_back();
11684 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
11685 self.state
11687 .push_log("Route: left edit sheet — press s to save current stops".to_string());
11688 }
11689 }
11690
11691 pub fn re_at_root_sheet(&self) -> bool {
11693 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
11694 matches!(
11695 ed.sheet,
11696 crate::worker_route_editor::RouteEditorSheet::Stops
11697 )
11698 })
11699 }
11700
11701 pub fn re_open_add_menu(&mut self) {
11702 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11703 ed.open_add_menu();
11704 }
11705 }
11706
11707 pub fn re_open_bed_picker(&mut self) {
11708 let beds = self.re_bed_candidates();
11709 if beds.is_empty() {
11710 self.state
11711 .push_log("Route: place a camp bed first".to_string());
11712 return;
11713 }
11714 let current = self
11715 .state
11716 .worker_route_editor
11717 .as_ref()
11718 .and_then(|ed| ed.lodging_container_id.clone());
11719 let index = current
11720 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
11721 .unwrap_or(0);
11722 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
11723 }
11724
11725 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
11726 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11727 ed.open_sheet(sheet);
11728 }
11729 }
11730
11731 fn re_confirm_stop(&mut self, stop: crate::worker_route_editor::WorkerRouteStop, what: String) {
11733 let appended = self
11734 .state
11735 .worker_route_editor
11736 .as_mut()
11737 .is_some_and(|ed| ed.confirm_stop(stop));
11738 if appended {
11739 self.state.push_log(format!("Route: + {what}"));
11740 } else {
11741 self.state
11742 .push_log(format!("Route: {what} already in route — selected it"));
11743 }
11744 }
11745
11746 fn re_open_withdraw_items(&mut self, container_id: String) {
11747 use crate::worker_route_editor::{
11748 RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
11749 };
11750 let contents = self.re_container_contents(&container_id);
11751 let existing = self
11755 .state
11756 .worker_route_editor
11757 .as_ref()
11758 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11759 .and_then(|stop| match stop {
11760 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
11761 _ => None,
11762 })
11763 .unwrap_or_default();
11764 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
11765 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11768 let _ = ed.retarget_withdraw_container(container_id.clone());
11769 }
11770 self.re_open_sheet(S::WithdrawItems {
11771 container_id,
11772 lines,
11773 index: 0,
11774 });
11775 }
11776
11777 fn re_withdraw_items_activate(&mut self, index: usize) {
11778 use crate::worker_route_editor::{
11779 RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
11780 };
11781 enum Outcome {
11782 Cycled,
11783 Confirmed(String),
11784 Empty,
11785 }
11786 let outcome = {
11787 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11788 return;
11789 };
11790 let S::WithdrawItems {
11791 container_id,
11792 lines,
11793 index: sheet_index,
11794 } = &mut ed.sheet
11795 else {
11796 return;
11797 };
11798 *sheet_index = index;
11799 if index < lines.len() {
11800 lines[index].cycle();
11801 Outcome::Cycled
11802 } else {
11803 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
11804 if items.is_empty() {
11805 Outcome::Empty
11806 } else {
11807 let stop = WorkerRouteStop::WithdrawFrom {
11808 container_id: container_id.clone(),
11809 items,
11810 };
11811 let summary = stop.summary();
11812 ed.confirm_stop(stop);
11813 Outcome::Confirmed(summary)
11814 }
11815 }
11816 };
11817 match outcome {
11818 Outcome::Cycled => {}
11819 Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
11820 Outcome::Empty => self.state.push_log(
11821 "Route: pick at least one item (Space/Enter toggles All/qty)".to_string(),
11822 ),
11823 }
11824 }
11825
11826 fn re_open_deposit_filter(&mut self, container_id: String) {
11827 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11828 let existing_filter = self
11830 .state
11831 .worker_route_editor
11832 .as_ref()
11833 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11834 .and_then(|stop| match stop {
11835 WorkerRouteStop::DepositAt { filter, .. } => {
11836 Some(filter.clone().unwrap_or_default())
11837 }
11838 _ => None,
11839 });
11840 let mut candidates = self.re_template_candidates();
11841 if let Some(ref chosen) = existing_filter {
11842 for t in chosen {
11843 if !candidates.iter().any(|c| c == t) {
11844 candidates.push(t.clone());
11845 }
11846 }
11847 candidates.sort();
11848 candidates.dedup();
11849 }
11850 let rows: Vec<(String, bool)> = match existing_filter {
11851 Some(chosen) => candidates
11852 .iter()
11853 .map(|t| (t.clone(), chosen.contains(t)))
11854 .collect(),
11855 None => candidates.into_iter().map(|t| (t, false)).collect(),
11856 };
11857 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11858 let _ = ed.retarget_deposit_container(container_id.clone());
11859 }
11860 self.re_open_sheet(S::DepositFilter {
11861 container_id,
11862 rows,
11863 index: 0,
11864 });
11865 }
11866
11867 fn re_deposit_filter_activate(&mut self, index: usize) {
11868 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11869 let mut confirmed: Option<String> = None;
11870 {
11871 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11872 return;
11873 };
11874 let S::DepositFilter {
11875 container_id,
11876 rows,
11877 index: sheet_index,
11878 } = &mut ed.sheet
11879 else {
11880 return;
11881 };
11882 *sheet_index = index;
11883 if index < rows.len() {
11884 rows[index].1 = !rows[index].1;
11885 } else {
11886 let chosen: Vec<String> = rows
11888 .iter()
11889 .filter(|(_, on)| *on)
11890 .map(|(t, _)| t.clone())
11891 .collect();
11892 let filter = if chosen.is_empty() {
11893 None
11894 } else {
11895 Some(chosen)
11896 };
11897 let stop = WorkerRouteStop::DepositAt {
11898 container_id: container_id.clone(),
11899 filter,
11900 };
11901 confirmed = Some(stop.summary());
11902 ed.confirm_stop(stop);
11903 }
11904 }
11905 if let Some(what) = confirmed {
11906 self.state.push_log(format!("Route: + {what}"));
11907 }
11908 }
11909
11910 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
11911 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11912 let (pre_npc, pre_template, pre_all) = self
11914 .state
11915 .worker_route_editor
11916 .as_ref()
11917 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11918 .and_then(|stop| match stop {
11919 WorkerRouteStop::TradeWith {
11920 npc_id,
11921 template,
11922 sell_all,
11923 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
11924 _ => None,
11925 })
11926 .unwrap_or((None, None, true));
11927 let npc_id = npc_id.or(pre_npc);
11928 let mut templates = crate::worker_route_editor::sellable_route_item_template_candidates(
11929 &self.re_template_candidates(),
11930 &self.state.npcs,
11931 npc_id.as_deref(),
11932 );
11933 if let Some(template) = pre_template.as_ref() {
11936 if !templates.iter().any(|candidate| candidate == template) {
11937 templates.push(template.clone());
11938 templates.sort();
11939 }
11940 }
11941 if templates.is_empty() {
11942 self.state
11943 .push_log("Route: no sellable item templates for that merchant".to_string());
11944 return;
11945 }
11946 let mut picked = std::collections::BTreeSet::new();
11947 if let Some(t) = pre_template {
11948 picked.insert(t);
11949 }
11950 self.re_open_sheet(S::SellItem {
11951 npc_id,
11952 templates,
11953 index: if picked.is_empty() {
11954 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
11955 } else {
11956 2
11957 },
11958 sell_all: pre_all,
11959 picked,
11960 });
11961 }
11962
11963 fn re_sell_item_activate(&mut self, index: usize) {
11964 use crate::worker_route_editor::{
11965 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
11966 };
11967 let mut batch_log: Option<String> = None;
11968 {
11969 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11970 return;
11971 };
11972 let S::SellItem {
11973 npc_id,
11974 templates,
11975 index: sheet_index,
11976 sell_all,
11977 picked,
11978 } = &mut ed.sheet
11979 else {
11980 return;
11981 };
11982 *sheet_index = index;
11983 if index == ROUTE_PICKER_DONE_ROW {
11984 if picked.is_empty() {
11985 batch_log =
11986 Some("Route: pick at least one item (Space toggles, Done confirms)".into());
11987 } else {
11988 let picks: Vec<String> = picked.iter().cloned().collect();
11989 let npc = npc_id.clone();
11990 let all = *sell_all;
11991 let added = ed.confirm_trade_picks(npc, &picks, all);
11992 batch_log = Some(format!("Route: + {added} sell stop(s)"));
11993 }
11994 } else if index == SELL_ITEM_TOGGLE_ROW {
11995 *sell_all = !*sell_all;
11996 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
11997 let sellable = crate::worker_route_editor::sellable_route_item_template_candidates(
11998 std::slice::from_ref(template),
11999 &self.state.npcs,
12000 npc_id.as_deref(),
12001 )
12002 .iter()
12003 .any(|candidate| candidate == template);
12004 if !sellable && !picked.contains(template) {
12005 return;
12006 }
12007 if picked.contains(template) {
12008 picked.remove(template);
12009 } else {
12010 picked.insert(template.clone());
12011 }
12012 }
12013 }
12014 if let Some(msg) = batch_log {
12015 self.state.push_log(msg);
12016 }
12017 }
12018
12019 pub fn re_edit_selected_stop(&mut self) {
12021 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12022 let Some(stop) = self
12023 .state
12024 .worker_route_editor
12025 .as_ref()
12026 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
12027 else {
12028 self.state
12029 .push_log("Route: no stop selected — press a to add one".to_string());
12030 return;
12031 };
12032 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12033 ed.begin_edit_selected();
12034 }
12035 match stop {
12036 WorkerRouteStop::Waypoint { .. } => {
12037 self.re_open_sheet(S::WaypointMenu { index: 0 });
12038 }
12039 WorkerRouteStop::HarvestNode { node_id } => {
12040 let nodes = self.state.route_editor_node_candidates();
12041 if nodes.is_empty() {
12042 self.re_cancel_edit();
12043 self.state
12044 .push_log("Route: no harvestable nodes visible to retarget".to_string());
12045 } else {
12046 let mut picked = std::collections::BTreeSet::new();
12047 picked.insert(node_id.clone());
12048 let index = nodes
12049 .iter()
12050 .position(|n| n.id == node_id)
12051 .map(|i| i + 1)
12052 .unwrap_or(1);
12053 self.re_open_harvest_picker(index, picked);
12054 }
12055 }
12056 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
12057 let containers = self.re_container_candidates();
12060 if containers.is_empty() {
12061 self.re_cancel_edit();
12062 self.state
12063 .push_log("Route: place a storage chest first".to_string());
12064 } else {
12065 let index = containers
12066 .iter()
12067 .position(|c| c.id == container_id)
12068 .unwrap_or(0);
12069 self.re_open_sheet(S::WithdrawContainers { index });
12070 }
12071 }
12072 WorkerRouteStop::DepositAt { container_id, .. } => {
12073 let containers = self.re_container_candidates();
12074 if containers.is_empty() {
12075 self.re_cancel_edit();
12076 self.state
12077 .push_log("Route: place a storage chest first".to_string());
12078 } else {
12079 let index = containers
12080 .iter()
12081 .position(|c| c.id == container_id)
12082 .unwrap_or(0);
12083 self.re_open_sheet(S::DepositContainers { index });
12084 }
12085 }
12086 WorkerRouteStop::TradeWith { npc_id, .. } => {
12087 let npcs = self.re_npc_candidates();
12088 let index = npc_id
12090 .as_ref()
12091 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
12092 .unwrap_or(0);
12093 self.re_open_sheet(S::SellNpcs { index });
12094 }
12095 WorkerRouteStop::CraftAt { blueprint, .. } => {
12096 let bps = self.re_blueprint_ids();
12097 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
12098 if bps.is_empty() {
12099 self.re_cancel_edit();
12100 self.state
12101 .push_log("Route: no known blueprints to retarget".to_string());
12102 } else {
12103 self.re_open_sheet(S::CraftBlueprint { index });
12104 }
12105 }
12106 WorkerRouteStop::CultivatePlot { .. } => {
12107 self.re_open_farm_plot_picker(
12108 crate::worker_route_editor::FarmPlotAction::Cultivate,
12109 );
12110 }
12111 WorkerRouteStop::PlantPlot { .. } => {
12112 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
12113 }
12114 WorkerRouteStop::HarvestPlot { .. } => {
12115 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
12116 }
12117 WorkerRouteStop::RestIfNeeded => {
12118 self.re_cancel_edit();
12119 self.state
12120 .push_log("Route: rest has no settings (change the bed with l)".to_string());
12121 }
12122 WorkerRouteStop::Wait { wait_ticks } => {
12123 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
12124 }
12125 }
12126 }
12127
12128 fn re_cancel_edit(&mut self) {
12129 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12130 ed.editing_index = None;
12131 }
12132 }
12133
12134 pub fn worker_route_editor_ui_click(
12137 &mut self,
12138 click: crate::worker_route_editor::RouteEditorClick,
12139 ) {
12140 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
12141 match click {
12142 RouteEditorClick::SelectStop(i) => {
12143 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12144 ed.sheet = S::Stops;
12145 ed.select_stop(i);
12146 }
12147 }
12148 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
12149 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
12150 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
12151 }
12152 }
12153
12154 pub fn re_sheet_row_activate(&mut self, row: usize) {
12156 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12157 let Some(sheet) = self
12158 .state
12159 .worker_route_editor
12160 .as_ref()
12161 .map(|ed| ed.sheet.clone())
12162 else {
12163 return;
12164 };
12165 match sheet {
12166 S::Stops => {
12167 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12168 ed.select_stop(row);
12169 }
12170 }
12171 S::AddMenu { .. } => match row {
12172 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
12173 1 => {
12174 if self.re_node_candidates().is_empty() {
12175 self.state.push_log(
12176 "Route: no harvestable nodes visible in this region".to_string(),
12177 );
12178 } else {
12179 self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
12180 }
12181 }
12182 2 | 3 => {
12183 if self.re_container_candidates().is_empty() {
12184 self.state
12185 .push_log("Route: place a storage chest first".to_string());
12186 } else if row == 2 {
12187 self.re_open_sheet(S::WithdrawContainers { index: 0 });
12188 } else {
12189 self.re_open_sheet(S::DepositContainers { index: 0 });
12190 }
12191 }
12192 4 => {
12193 if self.re_template_candidates().is_empty() {
12194 self.state.push_log(
12195 "Route: no item templates available — learn a craft recipe or place a harvest node first"
12196 .to_string(),
12197 );
12198 } else {
12199 self.re_open_sheet(S::SellNpcs { index: 0 });
12200 }
12201 }
12202 5 => {
12203 if self.re_blueprint_ids().is_empty() {
12204 self.state.push_log(
12205 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
12206 .to_string(),
12207 );
12208 } else {
12209 self.re_open_sheet(S::CraftBlueprint { index: 0 });
12210 }
12211 }
12212 6 => self.re_confirm_stop(
12213 WorkerRouteStop::RestIfNeeded,
12214 "rest at lodging (if needed)".into(),
12215 ),
12216 7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
12217 8 => self.re_open_farm_plot_picker(
12218 crate::worker_route_editor::FarmPlotAction::Cultivate,
12219 ),
12220 9 => {
12221 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant)
12222 }
12223 10 => self
12224 .re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
12225 _ => {}
12226 },
12227 S::WaypointMenu { .. } => match row {
12228 0 => {
12229 let (x, y, z) = self.state.player_position_with_z();
12230 let stop = WorkerRouteStop::Waypoint { x, y, z };
12231 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
12232 }
12233 1 => {
12234 self.re_open_sheet(S::WaypointMapPick);
12235 self.state.push_log(
12236 "Route: click the map to place the waypoint (Esc to finish)".to_string(),
12237 );
12238 }
12239 _ => {}
12240 },
12241 S::HarvestPicker { .. } => {
12242 let mut log: Option<String> = None;
12243 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12244 let S::HarvestPicker {
12245 index: sheet_index,
12246 picked,
12247 nodes,
12248 } = &mut ed.sheet
12249 else {
12250 return;
12251 };
12252 *sheet_index = row;
12253 if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
12254 if picked.is_empty() {
12255 log = Some(
12256 "Route: pick at least one node (Space toggles, Done confirms)"
12257 .into(),
12258 );
12259 } else {
12260 let ids: Vec<String> = picked.iter().cloned().collect();
12261 let added = ed.confirm_harvest_picks(&ids);
12262 log = Some(format!("Route: + {added} harvest stop(s)"));
12263 }
12264 } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
12265 if picked.contains(&n.id) {
12266 picked.remove(&n.id);
12267 } else {
12268 picked.insert(n.id.clone());
12269 }
12270 }
12271 }
12272 if let Some(msg) = log {
12273 self.state.push_log(msg);
12274 }
12275 }
12276 S::WithdrawContainers { .. } => {
12277 let containers = self.re_container_candidates();
12278 if let Some(c) = containers.get(row) {
12279 let id = c.id.clone();
12280 self.re_open_withdraw_items(id);
12281 }
12282 }
12283 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
12284 S::DepositContainers { .. } => {
12285 let containers = self.re_container_candidates();
12286 if let Some(c) = containers.get(row) {
12287 let id = c.id.clone();
12288 self.re_open_deposit_filter(id);
12289 }
12290 }
12291 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
12292 S::SellNpcs { .. } => {
12293 let npcs = self.re_npc_candidates();
12294 let npc_id = if row == 0 {
12295 None
12296 } else {
12297 npcs.get(row - 1).map(|n| n.id.clone())
12298 };
12299 if row == 0 || npc_id.is_some() {
12300 self.re_open_sell_item(npc_id);
12301 }
12302 }
12303 S::SellItem { .. } => self.re_sell_item_activate(row),
12304 S::CraftBlueprint { .. } => {
12305 let bps = self.re_blueprint_ids();
12306 if let Some(bp) = bps.get(row) {
12307 let stop = WorkerRouteStop::CraftAt {
12308 device: "hand".into(),
12309 blueprint: bp.clone(),
12310 qty: None,
12311 };
12312 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
12313 }
12314 }
12315 S::WaitEntry { ticks } => {
12316 let stop = WorkerRouteStop::Wait { wait_ticks: ticks };
12317 self.re_confirm_stop(stop, format!("wait {ticks}t"));
12318 }
12319 S::BedPicker { .. } => {
12320 let beds = self.re_bed_candidates();
12321 if let Some((id, name)) = beds.get(row) {
12322 let (id, name) = (id.clone(), name.clone());
12323 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12324 ed.lodging_container_id = Some(id.clone());
12325 ed.sheet = S::Stops;
12326 }
12327 self.state
12328 .push_log(format!("Route: rest bed set to {name}"));
12329 }
12330 }
12331 S::FarmPlotPicker { action, .. } => {
12332 let plots = self.re_farm_plot_candidates();
12333 let Some(plot) = plots.get(row).cloned() else {
12334 return;
12335 };
12336 match action {
12337 crate::worker_route_editor::FarmPlotAction::Cultivate => {
12338 let label = plot_route_label(&plot);
12339 self.re_confirm_stop(
12340 WorkerRouteStop::CultivatePlot {
12341 plot_id: plot.plot_id,
12342 },
12343 format!("cultivate {label}"),
12344 );
12345 }
12346 crate::worker_route_editor::FarmPlotAction::Harvest => {
12347 let label = plot_route_label(&plot);
12348 self.re_confirm_stop(
12349 WorkerRouteStop::HarvestPlot {
12350 plot_id: plot.plot_id,
12351 },
12352 format!("harvest {label}"),
12353 );
12354 }
12355 crate::worker_route_editor::FarmPlotAction::Plant => {
12356 let seeds = self.re_farm_seed_candidates();
12357 if seeds.is_empty() {
12358 self.state.push_log(
12359 "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
12360 );
12361 return;
12362 }
12363 self.re_open_sheet(S::FarmPlantSeed {
12364 plot_id: plot.plot_id,
12365 seeds,
12366 index: 0,
12367 });
12368 }
12369 }
12370 }
12371 S::FarmPlantSeed { plot_id, seeds, .. } => {
12372 if let Some(seed) = seeds.get(row).cloned() {
12373 self.re_confirm_stop(
12374 WorkerRouteStop::PlantPlot {
12375 plot_id,
12376 seed_template: seed.clone(),
12377 },
12378 format!("plant {seed}"),
12379 );
12380 }
12381 }
12382 S::WaypointMapPick => {}
12383 }
12384 }
12385
12386 fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
12387 use crate::worker_route_editor::RouteEditorSheet as S;
12388 if self.re_farm_plot_candidates().is_empty() {
12389 self.state
12390 .push_log("Route: no farmable plots visible — claim land or get farm access first");
12391 return;
12392 }
12393 self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
12394 }
12395
12396 fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
12397 self.state
12398 .property_plots
12399 .iter()
12400 .filter(|p| p.is_mine || p.may_farm)
12401 .cloned()
12402 .collect()
12403 }
12404
12405 fn re_farm_seed_candidates(&self) -> Vec<String> {
12409 let mut set = std::collections::BTreeSet::new();
12410 let looks_like_seed =
12411 |id: &str| id.ends_with("_seed") || id == "potato_seed" || id == "carrot_seed";
12412 for (id, _, _) in self.state.farm_seed_entries() {
12413 set.insert(id);
12414 }
12415 for c in &self.state.placed_containers {
12416 let mine = match (self.state.character_id, c.owner_character_id) {
12417 (Some(a), Some(b)) => a == b,
12418 _ => false,
12419 };
12420 if !mine {
12421 continue;
12422 }
12423 for s in &c.contents {
12424 if s.quantity > 0
12425 && (s.props.contains_key("seed_for") || looks_like_seed(&s.template_id))
12426 {
12427 set.insert(s.template_id.clone());
12428 }
12429 }
12430 }
12431 if let Some(ed) = self.state.worker_route_editor.as_ref() {
12432 for stop in &ed.stops {
12433 if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } =
12434 stop
12435 {
12436 for it in items {
12437 if looks_like_seed(&it.template) {
12438 set.insert(it.template.clone());
12439 }
12440 }
12441 }
12442 if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
12443 seed_template,
12444 ..
12445 } = stop
12446 {
12447 if !seed_template.is_empty() {
12448 set.insert(seed_template.clone());
12449 }
12450 }
12451 }
12452 }
12453 for id in self.state.inventory_hints.keys() {
12454 if looks_like_seed(id) {
12455 set.insert(id.clone());
12456 }
12457 }
12458 for id in ["potato_seed", "carrot_seed"] {
12460 set.insert(id.to_string());
12461 }
12462 set.into_iter().collect()
12463 }
12464
12465 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
12472 use crate::worker_route_editor as wre;
12473 use wre::RouteEditorSheet as S;
12474 if self.state.worker_route_editor.is_none() {
12475 return;
12476 }
12477 let sheet = self
12478 .state
12479 .worker_route_editor
12480 .as_ref()
12481 .map(|ed| ed.sheet.clone())
12482 .unwrap_or(S::Stops);
12483 match sheet {
12484 S::WaypointMapPick => {
12485 let (_, _, z) = self.state.player_position_with_z();
12486 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
12487 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
12488 let editing = self
12490 .state
12491 .worker_route_editor
12492 .as_ref()
12493 .is_some_and(|ed| ed.editing_index.is_some());
12494 if !editing {
12495 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12496 ed.sheet = S::WaypointMapPick;
12497 }
12498 }
12499 }
12500 S::HarvestPicker { .. } => {
12501 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
12502 let mut log: Option<String> = None;
12503 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12504 let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
12505 return;
12506 };
12507 let selected = if picked.contains(&node.id) {
12508 picked.remove(&node.id);
12509 false
12510 } else {
12511 picked.insert(node.id.clone());
12512 true
12513 };
12514 log = Some(format!(
12515 "Route: {} {}",
12516 if selected { "selected" } else { "deselected" },
12517 resource_node_route_label(node)
12518 ));
12519 }
12520 if let Some(msg) = log {
12521 self.state.push_log(msg);
12522 }
12523 }
12524 }
12525 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
12526 let inside = self.state.effective_inside_building();
12528 if let Some(cid) = wre::pick_storage_container_at(
12529 &self.state.placed_containers,
12530 self.state.character_id,
12531 x,
12532 y,
12533 inside.as_deref(),
12534 ) {
12535 self.re_open_withdraw_items(cid);
12536 }
12537 }
12538 S::DepositContainers { .. } | S::DepositFilter { .. } => {
12539 let inside = self.state.effective_inside_building();
12540 if let Some(cid) = wre::pick_storage_container_at(
12541 &self.state.placed_containers,
12542 self.state.character_id,
12543 x,
12544 y,
12545 inside.as_deref(),
12546 ) {
12547 self.re_open_deposit_filter(cid);
12548 }
12549 }
12550 S::SellNpcs { .. } => {
12551 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12552 self.re_open_sell_item(Some(npc_id));
12553 }
12554 }
12555 S::SellItem { .. } => {
12556 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12557 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12558 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
12559 *slot = Some(npc_id.clone());
12560 }
12561 }
12562 self.state
12563 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
12564 }
12565 }
12566 _ => self.worker_route_editor_quick_add_click(x, y),
12568 }
12569 }
12570
12571 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
12575 use crate::worker_route_editor as wre;
12576 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
12577 let dx = ax - bx;
12578 let dy = ay - by;
12579 (dx * dx + dy * dy).sqrt()
12580 };
12581
12582 let selected_stop_kind = self
12585 .state
12586 .worker_route_editor
12587 .as_ref()
12588 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
12589 .map(|s| match s {
12590 wre::WorkerRouteStop::TradeWith { .. } => 1,
12591 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
12592 _ => 0,
12593 })
12594 .unwrap_or(0);
12595 if selected_stop_kind == 1 {
12596 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12597 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12598 ed.set_selected_trade_npc(npc_id.clone());
12599 }
12600 self.state
12601 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
12602 return;
12603 }
12604 }
12605 if selected_stop_kind == 2 {
12606 let inside = self.state.effective_inside_building();
12607 if let Some(cid) = wre::pick_storage_container_at(
12608 &self.state.placed_containers,
12609 self.state.character_id,
12610 x,
12611 y,
12612 inside.as_deref(),
12613 ) {
12614 let name = self
12615 .state
12616 .placed_containers
12617 .iter()
12618 .find(|c| c.id == cid)
12619 .map(|c| c.display_name.clone())
12620 .unwrap_or_else(|| "container".into());
12621 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12622 ed.set_selected_withdraw_container(cid.clone());
12623 }
12624 self.state
12625 .push_log(format!("Route: withdraw source → {name}"));
12626 return;
12627 }
12628 }
12629
12630 enum Target {
12633 Bed(String),
12634 Container(String),
12635 Npc(String, String),
12636 Node(String, String),
12637 }
12638 let mut best: Option<(f32, u8, Target)> = None;
12639 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
12640 let better = match best {
12641 None => true,
12642 Some((bd, brank, _)) => {
12643 d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank)
12644 }
12645 };
12646 if better {
12647 *best = Some((d, rank, t));
12648 }
12649 };
12650 let inside = self.state.effective_inside_building();
12651 if let Some(bed_id) = wre::pick_lodging_container_at(
12652 &self.state.placed_containers,
12653 self.state.character_id,
12654 x,
12655 y,
12656 inside.as_deref(),
12657 ) {
12658 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
12659 let already_bed =
12662 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12663 ed.lodging_container_id.as_deref() == Some(bed_id.as_str())
12664 });
12665 if already_bed {
12666 consider(
12667 dist(x, y, c.x, c.y),
12668 1,
12669 Target::Container(bed_id),
12670 &mut best,
12671 );
12672 } else {
12673 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
12674 }
12675 }
12676 }
12677 if let Some(cid) = wre::pick_storage_container_at(
12678 &self.state.placed_containers,
12679 self.state.character_id,
12680 x,
12681 y,
12682 inside.as_deref(),
12683 ) {
12684 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
12685 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
12686 }
12687 }
12688 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12689 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
12690 consider(
12691 dist(x, y, n.x, n.y),
12692 2,
12693 Target::Npc(npc_id, label),
12694 &mut best,
12695 );
12696 }
12697 }
12698 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
12699 let d = dist(x, y, node.x, node.y);
12700 let label = resource_node_route_label(node);
12701 consider(d, 3, Target::Node(node.id.clone(), label), &mut best);
12702 }
12703
12704 match best.map(|(_, _, t)| t) {
12705 Some(Target::Bed(bed_id)) => {
12706 let name = self
12707 .state
12708 .placed_containers
12709 .iter()
12710 .find(|c| c.id == bed_id)
12711 .map(|c| c.display_name.clone())
12712 .unwrap_or_else(|| "camp bed".into());
12713 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12714 ed.lodging_container_id = Some(bed_id.clone());
12715 }
12716 self.state
12717 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
12718 }
12719 Some(Target::Container(cid)) => {
12720 let name = self
12721 .state
12722 .placed_containers
12723 .iter()
12724 .find(|c| c.id == cid)
12725 .map(|c| c.display_name.clone())
12726 .unwrap_or_else(|| "container".into());
12727 let added = self
12728 .state
12729 .worker_route_editor
12730 .as_mut()
12731 .is_some_and(|ed| ed.append_deposit_at(&cid));
12732 if added {
12733 self.state
12734 .push_log(format!("Route: + deposit at {name} ({cid})"));
12735 } else {
12736 self.state.push_log(format!(
12737 "Route: {name} already in route — selected it (d to remove)"
12738 ));
12739 }
12740 }
12741 Some(Target::Npc(npc_id, label)) => {
12742 let template = self.re_template_candidates().into_iter().next();
12745 let Some(template) = template else {
12746 self.state.push_log(
12747 "Route: no items in your storage to sell — stock a chest first".to_string(),
12748 );
12749 return;
12750 };
12751 let added = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
12752 ed.append_trade_with(template.clone(), Some(npc_id.clone()), true)
12753 });
12754 if added {
12755 self.state
12756 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
12757 } else {
12758 self.state.push_log(format!(
12759 "Route: {label} already sells {template} — selected it (d to remove)"
12760 ));
12761 }
12762 }
12763 Some(Target::Node(id, label)) => {
12764 let added = self
12765 .state
12766 .worker_route_editor
12767 .as_mut()
12768 .is_some_and(|ed| ed.append_harvest_node(&id));
12769 if added {
12770 self.state
12771 .push_log(format!("Route: + harvest node {label}"));
12772 } else {
12773 self.state.push_log(format!(
12774 "Route: {label} already in route — selected it (d to remove)"
12775 ));
12776 }
12777 }
12778 None => {}
12779 }
12780 }
12781
12782 pub fn worker_route_editor_select(&mut self, delta: i32) {
12783 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12784 return;
12785 };
12786 if ed.stops.is_empty() {
12787 return;
12788 }
12789 let n = ed.stops.len() as i32;
12790 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
12791 ed.selected_stop_index = next;
12792 }
12793
12794 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
12795 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12796 return;
12797 };
12798 if delta < 0 {
12799 ed.move_selected_up();
12800 } else if delta > 0 {
12801 ed.move_selected_down();
12802 }
12803 }
12804
12805 pub fn worker_route_editor_delete_selected(&mut self) {
12806 let removed = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
12807 let before = ed.stop_count();
12808 ed.remove_selected_stop();
12809 ed.stop_count() < before
12810 });
12811 if removed {
12812 self.state.push_log("Route: removed selected stop");
12813 }
12814 }
12815
12816 pub fn worker_route_editor_clear_stops(&mut self) {
12819 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12820 return;
12821 };
12822 if ed.stops.is_empty() {
12823 self.state
12824 .push_log("Route: already empty — s saves an idle worker".to_string());
12825 return;
12826 }
12827 ed.stops.clear();
12828 ed.selected_stop_index = 0;
12829 self.state.push_log(
12830 "Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string(),
12831 );
12832 }
12833
12834 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
12835 if self.state.pending_worker_job_ack.is_some() {
12836 anyhow::bail!("route save still pending — wait for server ack");
12837 }
12838 let Some(ed) = self.state.worker_route_editor.clone() else {
12839 anyhow::bail!("route editor not open");
12840 };
12841 let (job_yaml, idle) = if ed.stops.is_empty() {
12844 (ed.build_idle_job_yaml(), true)
12845 } else {
12846 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
12847 };
12848 let worker_id = ed.worker_instance_id.clone();
12849 let route_view = if idle { None } else { Some(ed.to_route_view()) };
12850 let mode = if idle {
12851 flatland_protocol::WorkerModeView::Idle
12852 } else {
12853 flatland_protocol::WorkerModeView::JobLoop
12854 };
12855 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
12856 .state
12857 .hired_workers
12858 .iter()
12859 .find(|w| w.instance_id == worker_id)
12860 .map(|w| {
12861 (
12862 w.route.clone(),
12863 w.mode,
12864 w.step_label.clone(),
12865 w.last_error.clone(),
12866 )
12867 })
12868 .unwrap_or((
12869 None,
12870 flatland_protocol::WorkerModeView::Idle,
12871 String::new(),
12872 None,
12873 ));
12874 self.seq += 1;
12875 let seq = self.seq;
12876 self.session
12877 .submit_intent(Intent::SetWorkerJob {
12878 entity_id: self.state.entity_id,
12879 worker_instance_id: worker_id.clone(),
12880 job_yaml,
12881 seq,
12882 })
12883 .await?;
12884 self.state.intents_sent += 1;
12885 if let Some(w) = self
12886 .state
12887 .hired_workers
12888 .iter_mut()
12889 .find(|w| w.instance_id == worker_id)
12890 {
12891 w.route = route_view;
12892 w.mode = mode;
12893 w.last_error = None;
12894 if idle {
12895 w.step_label.clear();
12896 w.route_stop_index = None;
12897 }
12898 }
12899 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
12900 seq,
12901 worker_instance_id: worker_id,
12902 worker_label: ed.worker_label.clone(),
12903 idle,
12904 stop_count: ed.stops.len(),
12905 prev_route,
12906 prev_mode,
12907 prev_step_label,
12908 prev_last_error,
12909 });
12910 self.state.push_log(format!(
12911 "Route: saving for {}… (waiting for server)",
12912 ed.worker_label
12913 ));
12914 Ok(())
12916 }
12917 pub fn quest_menu_move(&mut self, delta: i32) {
12918 let n = self.state.active_quest_entries().len();
12919 if n == 0 {
12920 return;
12921 }
12922 let idx = self.state.quest_menu_index as i32;
12923 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
12924 }
12925
12926 pub fn quest_menu_page(&mut self, pages: i32) {
12927 let n = self.state.active_quest_entries().len();
12928 self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
12929 }
12930
12931 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
12932 let Some(offer) = self.state.pending_quest_offer.clone() else {
12933 anyhow::bail!("no quest offer");
12934 };
12935 self.seq += 1;
12936 let seq = self.seq;
12937 self.session
12938 .submit_intent(Intent::AcceptQuest {
12939 entity_id: self.state.entity_id,
12940 quest_id: offer.quest_id,
12941 seq,
12942 })
12943 .await?;
12944 self.state.intents_sent += 1;
12945 Ok(())
12946 }
12947
12948 pub fn quest_offer_decline(&mut self) {
12949 self.state.show_quest_offer = false;
12950 self.state.pending_quest_offer = None;
12951 if !self.state.show_npc_chat
12952 && !self.state.show_shop_menu
12953 && self.state.npc_verb_target.is_some()
12954 {
12955 self.state.show_npc_verb_menu = true;
12956 }
12957 }
12958
12959 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
12960 if !self.state.show_quest_menu {
12961 return Ok(());
12962 }
12963 let active: Vec<_> = self
12964 .state
12965 .active_quest_entries()
12966 .into_iter()
12967 .cloned()
12968 .collect();
12969 let Some(entry) = active.get(self.state.quest_menu_index) else {
12970 return Ok(());
12971 };
12972 if self.state.quest_withdraw_confirm {
12973 if !entry.can_withdraw {
12974 anyhow::bail!("quest cannot be withdrawn");
12975 }
12976 self.seq += 1;
12977 let seq = self.seq;
12978 self.session
12979 .submit_intent(Intent::WithdrawQuest {
12980 entity_id: self.state.entity_id,
12981 quest_id: entry.quest_id.clone(),
12982 seq,
12983 })
12984 .await?;
12985 self.state.intents_sent += 1;
12986 self.state.quest_withdraw_confirm = false;
12987 return Ok(());
12988 }
12989 self.seq += 1;
12990 let seq = self.seq;
12991 self.session
12992 .submit_intent(Intent::TrackQuest {
12993 entity_id: self.state.entity_id,
12994 quest_id: entry.quest_id.clone(),
12995 seq,
12996 })
12997 .await?;
12998 self.state.intents_sent += 1;
12999 Ok(())
13000 }
13001
13002 pub fn quest_request_withdraw(&mut self) {
13003 if self.state.show_quest_menu {
13004 self.state.quest_withdraw_confirm = true;
13005 }
13006 }
13007
13008 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
13009 if !self.state.is_alive() {
13010 anyhow::bail!("you are dead");
13011 }
13012 let Some(catalog) = self.state.shop_catalog.clone() else {
13013 anyhow::bail!("no shop open");
13014 };
13015 self.seq += 1;
13016 let seq = self.seq;
13017 match self.state.shop_tab {
13018 ShopTab::Buy => {
13019 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
13020 anyhow::bail!("nothing selected");
13021 };
13022 if offer.already_owned {
13023 anyhow::bail!("already owned");
13024 }
13025 self.session
13026 .submit_intent(Intent::ShopBuy {
13027 entity_id: self.state.entity_id,
13028 npc_id: catalog.npc_id.clone(),
13029 offer_id: offer.offer_id.clone(),
13030 quantity: self.state.shop_quantity,
13031 seq,
13032 })
13033 .await?;
13034 }
13035 ShopTab::Sell => {
13036 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
13037 anyhow::bail!("nothing to sell");
13038 };
13039 if line.quantity == 0 {
13040 anyhow::bail!("you have no {}", line.label);
13041 }
13042 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
13043 self.session
13044 .submit_intent(Intent::ShopSell {
13045 entity_id: self.state.entity_id,
13046 npc_id: catalog.npc_id.clone(),
13047 template_id: line.template_id.clone(),
13048 quantity,
13049 seq,
13050 })
13051 .await?;
13052 }
13053 }
13054 self.state.intents_sent += 1;
13055 Ok(())
13056 }
13057
13058 pub fn craft_menu_move(&mut self, delta: i32) {
13059 let n = self.state.blueprints.len();
13060 if n == 0 {
13061 return;
13062 }
13063 let idx = self.state.craft_menu_index as i32;
13064 let next = (idx + delta).rem_euclid(n as i32);
13065 self.state.craft_menu_index = next as usize;
13066 self.state.clamp_craft_batch_quantity();
13067 }
13068
13069 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
13070 self.state.craft_batch_adjust_quantity(delta);
13071 }
13072
13073 pub fn craft_batch_set_max(&mut self) {
13074 self.state.craft_batch_set_max();
13075 }
13076
13077 pub fn craft_batch_set_min(&mut self) {
13078 self.state.craft_batch_set_min();
13079 }
13080
13081 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
13082 let Some(blueprint) = self
13083 .state
13084 .blueprints
13085 .get(self.state.craft_menu_index)
13086 .cloned()
13087 else {
13088 anyhow::bail!("no blueprints known");
13089 };
13090 if !self.state.can_craft_blueprint(&blueprint) {
13091 let hint = self
13092 .state
13093 .craft_missing_hint(&blueprint)
13094 .unwrap_or_else(|| "missing materials".into());
13095 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
13096 }
13097 let count = self.state.craft_batch_quantity;
13098 let max = self.state.max_craft_batches(&blueprint);
13099 if max == 0 {
13100 anyhow::bail!("cannot craft {}", blueprint.label);
13101 }
13102 let batches = count.min(max);
13103 self.craft(&blueprint.id, Some(batches)).await?;
13104 self.state.show_craft_menu = false;
13105 Ok(())
13106 }
13107
13108 pub async fn move_by(
13109 &mut self,
13110 forward: f32,
13111 strafe: f32,
13112 vertical: f32,
13113 sprint: bool,
13114 sneak: bool,
13115 ) -> anyhow::Result<()> {
13116 if !self.state.is_alive() {
13117 anyhow::bail!("you are dead");
13118 }
13119 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
13120 self.last_move_forward = forward;
13121 self.last_move_strafe = strafe;
13122 }
13123 self.seq += 1;
13124 self.session
13125 .submit_intent(Intent::Move {
13126 entity_id: self.state.entity_id,
13127 forward,
13128 strafe,
13129 vertical,
13130 sprint: sprint && !sneak,
13131 sneak,
13132 seq: self.seq,
13133 })
13134 .await?;
13135 self.state.intents_sent += 1;
13136 Ok(())
13137 }
13138
13139 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
13140 if !self.state.connected {
13141 crate::harvest_trace!("harvest_nearest rejected: not connected");
13142 anyhow::bail!("not connected");
13143 }
13144 if !self.state.is_alive() {
13145 crate::harvest_trace!("harvest_nearest rejected: player dead");
13146 anyhow::bail!("you are dead");
13147 }
13148 if self.state.harvest_in_progress {
13149 if self.state.harvest_state_stale() {
13150 self.state.clear_harvest_state();
13151 } else {
13152 anyhow::bail!("already harvesting");
13153 }
13154 }
13155 let (px, py) = self
13156 .state
13157 .player
13158 .as_ref()
13159 .map(|p| (p.transform.position.x, p.transform.position.y))
13160 .unwrap_or((0.0, 0.0));
13161
13162 let available = self
13163 .state
13164 .resource_nodes
13165 .iter()
13166 .filter(|n| !n.harvest_off)
13167 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
13168 .count();
13169 let node_id = self
13170 .state
13171 .resource_nodes
13172 .iter()
13173 .filter(|n| !n.harvest_off)
13174 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
13175 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
13176 .min_by(|a, b| {
13177 let da = distance(px, py, a.x, a.y);
13178 let db = distance(px, py, b.x, b.y);
13179 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
13180 })
13181 .map(|n| n.id.clone());
13182
13183 let Some(node_id) = node_id else {
13184 let has_loot = self
13185 .state
13186 .ground_drops
13187 .iter()
13188 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
13189 if has_loot {
13190 return self.pickup_nearest().await;
13191 }
13192 anyhow::bail!(
13193 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
13194 );
13195 };
13196
13197 self.seq += 1;
13198 let seq = self.seq;
13199 crate::harvest_trace!(
13200 entity_id = self.state.entity_id,
13201 node_id = %node_id,
13202 seq,
13203 px,
13204 py,
13205 available_nodes = available,
13206 "submitting harvest intent"
13207 );
13208 self.session
13209 .submit_intent(Intent::Harvest {
13210 entity_id: self.state.entity_id,
13211 node_id,
13212 seq,
13213 })
13214 .await?;
13215 self.state.intents_sent += 1;
13216 self.state.harvest_in_progress = true;
13217 self.state.harvest_started_at = Some(Instant::now());
13218 self.state.push_log("Harvesting…");
13219 crate::harvest_trace!(
13220 entity_id = self.state.entity_id,
13221 seq,
13222 "harvest intent queued to session"
13223 );
13224 Ok(())
13225 }
13226
13227 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
13228 if !self.state.is_alive() {
13229 anyhow::bail!("you are dead");
13230 }
13231 let blueprint_id = self
13232 .state
13233 .blueprints
13234 .iter()
13235 .find(|bp| self.state.can_craft_blueprint(bp))
13236 .map(|bp| bp.id.clone())
13237 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
13238 self.craft(&blueprint_id, None).await
13239 }
13240
13241 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
13242 if !self.state.is_alive() {
13243 anyhow::bail!("you are dead");
13244 }
13245 self.seq += 1;
13246 self.session
13247 .submit_intent(Intent::Craft {
13248 entity_id: self.state.entity_id,
13249 blueprint_id: blueprint_id.to_string(),
13250 count,
13251 seq: self.seq,
13252 })
13253 .await?;
13254 self.state.intents_sent += 1;
13255 let (label, batches) = self
13256 .state
13257 .blueprints
13258 .iter()
13259 .find(|b| b.id == blueprint_id)
13260 .map(|b| {
13261 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
13262 (b.label.as_str(), n)
13263 })
13264 .unwrap_or((blueprint_id, count.unwrap_or(1)));
13265 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
13266 Ok(())
13267 }
13268
13269 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
13270 if !self.state.is_alive() {
13271 anyhow::bail!("you are dead");
13272 }
13273 let target_id = match self.state.nearest_interact_target() {
13274 Some(id) => id,
13275 None => {
13276 anyhow::bail!("nothing to interact with nearby");
13277 }
13278 };
13279 if self.state.npcs.iter().any(|n| n.id == target_id) {
13280 self.state.show_npc_verb_menu = true;
13281 self.state.npc_verb_target = Some(target_id);
13282 self.state.npc_verb_index = 0;
13283 return Ok(());
13284 }
13285 if self
13286 .state
13287 .hired_workers
13288 .iter()
13289 .any(|w| w.instance_id == target_id)
13290 {
13291 return self.open_workers_menu_for(&target_id).await;
13292 }
13293 if let Ok(peer_id) = target_id.parse::<EntityId>() {
13294 if self
13295 .state
13296 .hired_workers
13297 .iter()
13298 .any(|w| w.entity_id == peer_id)
13299 {
13300 if let Some(w) = self
13301 .state
13302 .hired_workers
13303 .iter()
13304 .find(|w| w.entity_id == peer_id)
13305 {
13306 let id = w.instance_id.clone();
13307 return self.open_workers_menu_for(&id).await;
13308 }
13309 }
13310 if let Some(entity) = self
13311 .state
13312 .entities
13313 .iter()
13314 .find(|e| e.id == peer_id && e.id != self.state.entity_id)
13315 {
13316 self.state.player_verbs.open_for(peer_id, &entity.label);
13317 return Ok(());
13318 }
13319 }
13320 self.seq += 1;
13321 self.session
13322 .submit_intent(Intent::Interact {
13323 entity_id: self.state.entity_id,
13324 target_id: target_id.clone(),
13325 seq: self.seq,
13326 })
13327 .await?;
13328 self.state.intents_sent += 1;
13329 Ok(())
13330 }
13331
13332 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
13334 if !self.state.is_alive() {
13335 anyhow::bail!("you are dead");
13336 }
13337 let (px, py) = self.state.player_position();
13338 let has_loot = self
13339 .state
13340 .ground_drops
13341 .iter()
13342 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
13343 if has_loot {
13344 return self.pickup_nearest().await;
13345 }
13346 if self
13347 .state
13348 .placed_containers
13349 .iter()
13350 .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
13351 {
13352 return self.pickup_nearest_container().await;
13353 }
13354
13355 if let Some(plot) = self.state.my_plot_under_player().cloned() {
13356 const SELL_WINDOW: Duration = Duration::from_millis(1200);
13358 let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
13359 && self
13360 .state
13361 .sell_plot_armed_at
13362 .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
13363 if sell_armed {
13364 return self.confirm_sell_plot_to_crown(plot.plot_id).await;
13365 }
13366 self.state.sell_plot_confirm = None;
13367 self.state.sell_plot_armed_at = None;
13368
13369 let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
13372 self.state.npcs.iter().any(|n| n.id == id)
13373 || self.state.hired_workers.iter().any(|w| w.instance_id == id)
13374 || self.state.doors.iter().any(|d| d.id == id)
13375 || self.state.interactables.iter().any(|i| {
13376 i.id == id
13377 && matches!(i.kind.as_str(), "quest_board" | "well" | "exit" | "enter")
13378 })
13379 || id.parse::<EntityId>().is_ok_and(|eid| {
13380 self.state
13381 .entities
13382 .iter()
13383 .any(|e| e.id == eid && e.id != self.state.entity_id)
13384 })
13385 });
13386 if !blocking_interact {
13387 match self.harvest_nearest().await {
13389 Ok(()) => return Ok(()),
13390 Err(err) => {
13391 let msg = err.to_string();
13392 if !(msg.contains("no harvestable")
13393 || msg.contains("press p")
13394 || msg.contains("press f")
13395 || msg.contains("nothing"))
13396 {
13397 return Err(err);
13398 }
13399 }
13400 }
13401 return Ok(());
13402 }
13403 }
13404 if self.state.nearest_interact_target().is_some() {
13405 return self.interact_nearest().await;
13406 }
13407 if let Some((label, dist)) = self.state.nearest_quest_board() {
13410 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
13411 anyhow::bail!(
13412 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
13413 );
13414 }
13415 }
13416
13417 match self.harvest_nearest().await {
13418 Ok(()) => Ok(()),
13419 Err(err) => {
13420 let msg = err.to_string();
13421 if msg.contains("no harvestable")
13422 || msg.contains("press p")
13423 || msg.contains("press f")
13424 {
13425 anyhow::bail!(
13426 "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
13427 );
13428 }
13429 Err(err)
13430 }
13431 }
13432 }
13433
13434 pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
13436 if !self.state.is_alive() {
13437 anyhow::bail!("you are dead");
13438 }
13439 if self.state.claim_mode.is_some() {
13440 anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
13441 }
13442 let zone = self
13443 .state
13444 .free_property_zone_under_player()
13445 .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
13446 let zone_id = zone.id.clone();
13447 let label = zone
13448 .label
13449 .as_deref()
13450 .filter(|s| !s.trim().is_empty())
13451 .unwrap_or(zone.id.as_str())
13452 .to_string();
13453 self.enter_claim_mode(&zone_id);
13454 self.state.push_log(format!(
13455 "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
13456 ));
13457 Ok(())
13458 }
13459
13460 pub fn enter_claim_mode(&mut self, zone_id: &str) {
13462 let Some(zone) = self
13463 .state
13464 .property_zones
13465 .iter()
13466 .find(|z| z.id == zone_id)
13467 .cloned()
13468 else {
13469 self.state.push_log("unknown property zone");
13470 return;
13471 };
13472 self.state.sell_plot_confirm = None;
13473 self.state.sell_plot_armed_at = None;
13474 let min_area = self
13475 .state
13476 .property_plot_settings
13477 .as_ref()
13478 .map(|s| s.min_plot_area_m2)
13479 .unwrap_or(4.0)
13480 .max(1.0);
13481 let min_side = min_area.sqrt().ceil().max(1.0) as u32;
13482 let side = 4u32.max(min_side);
13483 let (px, py) = self.state.player_position();
13484 let anchor_x = px.floor();
13485 let anchor_y = py.floor();
13486 self.state.claim_mode = Some(ClaimModeState {
13487 zone_id: zone.id.clone(),
13488 width_m: side,
13489 height_m: side,
13490 anchor_x,
13491 anchor_y,
13492 });
13493 let label = zone
13494 .label
13495 .as_deref()
13496 .filter(|s| !s.trim().is_empty())
13497 .unwrap_or(zone.id.as_str());
13498 self.state.push_log(format!(
13499 "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
13500 ));
13501 }
13502
13503 pub fn cancel_claim_mode(&mut self) {
13504 if self.state.claim_mode.take().is_some() {
13505 self.state.push_log("Claim cancelled");
13506 }
13507 }
13508
13509 pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
13511 if !self.state.is_alive() {
13512 anyhow::bail!("you are dead");
13513 }
13514 if self.state.relocate_mode.is_some() {
13515 anyhow::bail!("already relocating — Enter confirm, Esc cancel");
13516 }
13517 if self.state.claim_mode.is_some() {
13518 anyhow::bail!("finish or cancel claim mode first");
13519 }
13520 let chest = self
13521 .state
13522 .placed_containers
13523 .iter()
13524 .find(|c| c.id == container_id)
13525 .cloned()
13526 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
13527 let (px, py) = self.state.player_position();
13528 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
13529 anyhow::bail!("too far from {}", chest.display_name);
13530 }
13531 if chest.locked && !chest.accessible {
13532 anyhow::bail!(
13533 "need the matching key for {} before moving it",
13534 chest.display_name
13535 );
13536 }
13537 let label = if chest.display_name.trim().is_empty() {
13538 chest.template_id.clone()
13539 } else {
13540 chest.display_name.clone()
13541 };
13542 self.state.relocate_mode = Some(RelocateModeState {
13543 container_id: chest.id.clone(),
13544 label: label.clone(),
13545 cursor_x: chest.x.floor() + 0.5,
13546 cursor_y: chest.y.floor() + 0.5,
13547 });
13548 self.state.push_log(format!(
13549 "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
13550 ));
13551 Ok(())
13552 }
13553
13554 pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
13556 let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
13557 anyhow::bail!("no chest nearby to relocate");
13558 };
13559 if chest.locked && !chest.accessible {
13560 anyhow::bail!(
13561 "need the matching key for {} before moving it",
13562 chest.display_name
13563 );
13564 }
13565 self.begin_relocate_container(&chest.id)
13568 }
13569
13570 pub fn cancel_relocate_mode(&mut self) {
13571 if self.state.relocate_mode.take().is_some() {
13572 self.state.push_log("Relocate cancelled");
13573 }
13574 }
13575
13576 pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
13577 let Some(mode) = self.state.relocate_mode.as_mut() else {
13578 return;
13579 };
13580 let max_x = self.state.world_width_m.max(1.0);
13581 let max_y = self.state.world_height_m.max(1.0);
13582 let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
13583 let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
13584 mode.cursor_x = nx.floor() + 0.5;
13585 mode.cursor_y = ny.floor() + 0.5;
13586 }
13587
13588 pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
13589 let Some(mode) = self.state.relocate_mode.as_mut() else {
13590 return;
13591 };
13592 let max_x = self.state.world_width_m.max(1.0);
13593 let max_y = self.state.world_height_m.max(1.0);
13594 mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
13595 mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
13596 }
13597
13598 pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
13599 if !self.state.is_alive() {
13600 anyhow::bail!("you are dead");
13601 }
13602 let Some(mode) = self.state.relocate_mode.clone() else {
13603 anyhow::bail!("not relocating");
13604 };
13605 let (px, py) = self.state.player_position();
13606 let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
13607 if dist > 8.0 {
13608 anyhow::bail!("destination too far (max 8 m)");
13609 }
13610 self.seq += 1;
13611 self.session
13612 .submit_intent(Intent::MovePlacedContainer {
13613 entity_id: self.state.entity_id,
13614 container_id: mode.container_id.clone(),
13615 x: mode.cursor_x,
13616 y: mode.cursor_y,
13617 seq: self.seq,
13618 })
13619 .await?;
13620 self.state.intents_sent += 1;
13621 self.state.relocate_mode = None;
13622 self.state.push_log(format!("Moving {}…", mode.label));
13623 Ok(())
13624 }
13625
13626 pub fn claim_set_preset(&mut self, w: u32, h: u32) {
13627 let Some(mode) = self.state.claim_mode.as_mut() else {
13628 return;
13629 };
13630 mode.width_m = w.max(1);
13631 mode.height_m = h.max(1);
13632 }
13633
13634 pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
13635 let Some(mode) = self.state.claim_mode.as_mut() else {
13636 return;
13637 };
13638 let w = (mode.width_m as i32 + dw).max(1) as u32;
13639 let h = (mode.height_m as i32 + dh).max(1) as u32;
13640 mode.width_m = w;
13641 mode.height_m = h;
13642 }
13643
13644 pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
13646 let Some(mode) = self.state.claim_mode.as_mut() else {
13647 return;
13648 };
13649 let max_x = self.state.world_width_m.max(1.0);
13650 let max_y = self.state.world_height_m.max(1.0);
13651 let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
13652 let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
13653 mode.anchor_x = nx.floor();
13654 mode.anchor_y = ny.floor();
13655 }
13656
13657 pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
13658 if !self.state.is_alive() {
13659 anyhow::bail!("you are dead");
13660 }
13661 let Some(mode) = self.state.claim_mode.clone() else {
13662 anyhow::bail!("not in claim mode");
13663 };
13664 let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
13665 self.state.claim_quote()
13666 else {
13667 anyhow::bail!("cannot quote claim");
13668 };
13669 if !valid {
13670 anyhow::bail!(reason);
13671 }
13672 if !can_afford {
13673 anyhow::bail!(
13674 "not enough copper (need {})",
13675 crate::currency::format_copper(purchase)
13676 );
13677 }
13678 let (x0, y0, x1, y1) = self
13679 .state
13680 .claim_footprint_rect()
13681 .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
13682 let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
13683 self.seq += 1;
13684 self.session
13685 .submit_intent(Intent::BuyPlot {
13686 entity_id: self.state.entity_id,
13687 zone_id: mode.zone_id,
13688 x0,
13689 y0,
13690 x1,
13691 y1,
13692 seq: self.seq,
13693 })
13694 .await?;
13695 self.state.intents_sent += 1;
13696 self.state.claim_mode = None;
13697 self.state.push_log(format!(
13698 "Buying plot for {}",
13699 crate::currency::format_copper(purchase)
13700 ));
13701 Ok(())
13702 }
13703
13704 pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
13705 if !self.state.is_alive() {
13706 anyhow::bail!("you are dead");
13707 }
13708 let zone_id = self
13709 .state
13710 .claim_mode
13711 .as_ref()
13712 .map(|m| m.zone_id.clone())
13713 .or_else(|| {
13714 self.state
13715 .free_property_zone_under_player()
13716 .map(|z| z.id.clone())
13717 })
13718 .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
13719 self.seq += 1;
13720 self.session
13721 .submit_intent(Intent::BuyPlotAllFree {
13722 entity_id: self.state.entity_id,
13723 zone_id,
13724 seq: self.seq,
13725 })
13726 .await?;
13727 self.state.intents_sent += 1;
13728 self.state.claim_mode = None;
13729 self.state.push_log("Claiming largest free plot…");
13730 Ok(())
13731 }
13732
13733 pub async fn confirm_sell_plot_to_crown(&mut self, plot_id: uuid::Uuid) -> anyhow::Result<()> {
13734 if !self.state.is_alive() {
13735 anyhow::bail!("you are dead");
13736 }
13737 self.seq += 1;
13738 self.session
13739 .submit_intent(Intent::SellPlotToCrown {
13740 entity_id: self.state.entity_id,
13741 plot_id,
13742 seq: self.seq,
13743 })
13744 .await?;
13745 self.state.intents_sent += 1;
13746 self.state.sell_plot_confirm = None;
13747 self.state.sell_plot_armed_at = None;
13748 self.state.push_log("Selling plot to the crown…");
13749 Ok(())
13750 }
13751
13752 pub async fn set_plot_farm_public(
13753 &mut self,
13754 plot_id: uuid::Uuid,
13755 public: bool,
13756 public_tax_discount_bps: u32,
13757 ) -> anyhow::Result<()> {
13758 self.seq += 1;
13759 self.session
13760 .submit_intent(Intent::SetPlotFarmPublic {
13761 entity_id: self.state.entity_id,
13762 plot_id,
13763 public,
13764 public_tax_discount_bps,
13765 seq: self.seq,
13766 })
13767 .await?;
13768 self.state.intents_sent += 1;
13769 Ok(())
13770 }
13771
13772 pub async fn plot_farm_allow_upsert(
13773 &mut self,
13774 plot_id: uuid::Uuid,
13775 character_id: Option<uuid::Uuid>,
13776 character_name: String,
13777 tax_discount_bps: u32,
13778 ) -> anyhow::Result<()> {
13779 self.seq += 1;
13780 self.session
13781 .submit_intent(Intent::PlotFarmAllowUpsert {
13782 entity_id: self.state.entity_id,
13783 plot_id,
13784 character_id,
13785 character_name,
13786 tax_discount_bps,
13787 seq: self.seq,
13788 })
13789 .await?;
13790 self.state.intents_sent += 1;
13791 Ok(())
13792 }
13793
13794 pub async fn plot_farm_allow_remove(
13795 &mut self,
13796 plot_id: uuid::Uuid,
13797 character_id: uuid::Uuid,
13798 ) -> anyhow::Result<()> {
13799 self.seq += 1;
13800 self.session
13801 .submit_intent(Intent::PlotFarmAllowRemove {
13802 entity_id: self.state.entity_id,
13803 plot_id,
13804 character_id,
13805 seq: self.seq,
13806 })
13807 .await?;
13808 self.state.intents_sent += 1;
13809 Ok(())
13810 }
13811
13812 pub fn open_farm_access_panel(&mut self) {
13813 let Some(plot) = self.state.my_plot_under_player() else {
13814 self.state
13815 .push_log("Stand on your deed plot to manage farm access");
13816 return;
13817 };
13818 self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
13819 self.state.farm_access_index = 0;
13820 self.state.show_farm_access = true;
13821 }
13822
13823 pub fn close_farm_access_panel(&mut self) {
13824 self.state.show_farm_access = false;
13825 self.state.farm_access_name_draft.clear();
13826 self.state.farm_access_index = 0;
13827 }
13828
13829 pub fn farm_access_move(&mut self, delta: i32) {
13830 let n = self.farm_access_row_count().max(1);
13831 let idx = self.state.farm_access_index as i32 + delta;
13832 self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
13833 }
13834
13835 pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
13836 let Some(plot) = self.state.my_plot_under_player() else {
13837 return vec![FarmAccessRow::PublicToggle];
13838 };
13839 let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
13840 for g in &plot.farm_allow {
13841 rows.push(FarmAccessRow::AllowRemove {
13842 character_id: g.character_id,
13843 label: if g.character_label.trim().is_empty() {
13844 g.character_id.to_string()[..8].to_string()
13845 } else {
13846 g.character_label.clone()
13847 },
13848 tax_discount_bps: g.tax_discount_bps,
13849 });
13850 }
13851 for e in &self.state.entities {
13852 if e.id == self.state.entity_id || e.label.trim().is_empty() {
13853 continue;
13854 }
13855 if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
13856 continue;
13857 }
13858 if self
13859 .state
13860 .npcs
13861 .iter()
13862 .any(|n| n.id == e.label || n.label == e.label)
13863 {
13864 continue;
13865 }
13866 if plot
13867 .farm_allow
13868 .iter()
13869 .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
13870 {
13871 continue;
13872 }
13873 rows.push(FarmAccessRow::NearbyAdd {
13874 name: e.label.clone(),
13875 });
13876 }
13877 rows
13878 }
13879
13880 pub fn farm_access_row_count(&self) -> usize {
13881 self.farm_access_rows().len().max(1)
13882 }
13883
13884 pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
13885 let Some(plot) = self.state.my_plot_under_player().cloned() else {
13886 self.close_farm_access_panel();
13887 return Ok(());
13888 };
13889 let rows = self.farm_access_rows();
13890 let Some(row) = rows.get(self.state.farm_access_index) else {
13891 return Ok(());
13892 };
13893 match row {
13894 FarmAccessRow::PublicToggle => {
13895 self.set_plot_farm_public(
13896 plot.plot_id,
13897 !plot.farm_public,
13898 plot.public_tax_discount_bps,
13899 )
13900 .await
13901 }
13902 FarmAccessRow::PublicDiscount => Ok(()),
13903 FarmAccessRow::AllowRemove { character_id, .. } => {
13904 self.plot_farm_allow_remove(plot.plot_id, *character_id)
13905 .await
13906 }
13907 FarmAccessRow::NearbyAdd { name } => {
13908 let disc = self
13909 .state
13910 .farm_access_discount_bps
13911 .max(plot.public_tax_discount_bps);
13912 self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
13913 .await
13914 }
13915 }
13916 }
13917
13918 pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
13919 let Some(plot) = self.state.my_plot_under_player().cloned() else {
13920 return Ok(());
13921 };
13922 let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
13923 self.state.farm_access_discount_bps = next;
13924 self.state.farm_access_index = 1;
13925 self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
13926 .await
13927 }
13928
13929 pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
13931 if self.state.farmable_plot_under_player().is_none() {
13932 anyhow::bail!("stand on a farmable plot to cultivate");
13933 }
13934 let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
13935 let (px, py) = self.state.player_position();
13936 if self
13937 .state
13938 .terrain_at(px, py)
13939 .is_some_and(|k| k == TerrainKindView::Tilled)
13940 {
13941 anyhow::bail!("already tilled — stand on bare soil and press c");
13942 }
13943 anyhow::bail!("cannot till this cell — move onto soil on your plot");
13944 };
13945 self.cultivate_at(tx, ty).await
13946 }
13947
13948 pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
13950 if self.state.farmable_plot_under_player().is_none() {
13951 anyhow::bail!("stand on a farmable plot to plant");
13952 }
13953 if !self.state.underfoot_free_tilled_plant_slot() {
13954 anyhow::bail!("stand on empty tilled soil and press p");
13955 }
13956 let seeds = self.state.farm_seed_entries();
13957 if seeds.is_empty() {
13958 anyhow::bail!("no seeds in inventory — buy seeds from Eli");
13959 }
13960 if seeds.len() == 1 {
13961 return self.plant_seeds(seeds[0].0.clone(), 1).await;
13962 }
13963 self.open_plant_menu();
13964 Ok(())
13965 }
13966
13967 pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
13969 let Some(plot) = self.state.my_plot_under_player() else {
13970 anyhow::bail!("stand on your plot to build");
13971 };
13972 if plot.building_id.is_some() {
13973 anyhow::bail!("this plot already has a building");
13974 }
13975 let building_now = self
13976 .state
13977 .timed_channel
13978 .as_ref()
13979 .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
13980 if !building_now && self.state.building_materials.is_empty() {
13981 anyhow::bail!("no building materials loaded — wait a moment and try again");
13982 }
13983 self.state.show_plot_build_menu = true;
13984 self.state.show_craft_menu = false;
13985 self.state.show_shop_menu = false;
13986 self.state.shop_catalog = None;
13987 self.state.show_stats = false;
13988 self.state.show_inventory_menu = false;
13989 self.state.plot_build_focus_wall = true;
13990 let walls = self.state.plot_build_wall_options().len();
13991 let roofs = self.state.plot_build_roof_options().len();
13992 if walls > 0 {
13993 self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
13994 } else {
13995 self.state.plot_build_wall_index = 0;
13996 }
13997 if roofs > 0 {
13998 self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
13999 } else {
14000 self.state.plot_build_roof_index = 0;
14001 }
14002 Ok(())
14003 }
14004
14005 pub fn close_plot_build_menu(&mut self) {
14006 self.state.show_plot_build_menu = false;
14007 }
14008
14009 pub fn plot_build_menu_move(&mut self, delta: i32) {
14010 let walls = self.state.plot_build_wall_options();
14011 let roofs = self.state.plot_build_roof_options();
14012 if self.state.plot_build_focus_wall {
14013 if walls.is_empty() {
14014 return;
14015 }
14016 let n = walls.len() as i32;
14017 let cur = self.state.plot_build_wall_index as i32;
14018 self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
14019 } else {
14020 if roofs.is_empty() {
14021 return;
14022 }
14023 let n = roofs.len() as i32;
14024 let cur = self.state.plot_build_roof_index as i32;
14025 self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
14026 }
14027 }
14028
14029 pub fn plot_build_menu_toggle_focus(&mut self) {
14030 self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
14031 }
14032
14033 pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
14035 let wall = self
14036 .state
14037 .plot_build_selected_wall()
14038 .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
14039 .id
14040 .clone();
14041 let roof = self
14042 .state
14043 .plot_build_selected_roof()
14044 .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
14045 .id
14046 .clone();
14047 self.start_plot_build(&wall, &roof).await
14049 }
14050
14051 pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
14053 self.seq += 1;
14054 self.session
14055 .submit_intent(Intent::CancelPlotBuild {
14056 entity_id: self.state.entity_id,
14057 seq: self.seq,
14058 })
14059 .await?;
14060 self.state.intents_sent += 1;
14061 Ok(())
14062 }
14063
14064 pub async fn start_plot_build(
14066 &mut self,
14067 wall_material_id: &str,
14068 roof_material_id: &str,
14069 ) -> anyhow::Result<()> {
14070 let Some(plot) = self.state.my_plot_under_player() else {
14071 anyhow::bail!("stand on your plot to build");
14072 };
14073 if plot.building_id.is_some() {
14074 anyhow::bail!("this plot already has a building");
14075 }
14076 let plot_id = plot.plot_id;
14077 self.seq += 1;
14078 self.session
14079 .submit_intent(Intent::StartPlotBuild {
14080 entity_id: self.state.entity_id,
14081 plot_id,
14082 wall_material_id: wall_material_id.to_string(),
14083 roof_material_id: roof_material_id.to_string(),
14084 seq: self.seq,
14085 })
14086 .await?;
14087 self.state.intents_sent += 1;
14088 Ok(())
14089 }
14090
14091 pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
14093 let (px, py) = self.state.player_position();
14094 let mut best: Option<(f32, String, bool)> = None;
14095 for d in &self.state.doors {
14096 if d.lock_id.is_none() {
14097 continue;
14098 }
14099 let dist = (d.x - px).hypot(d.y - py);
14100 if dist > DOOR_INTERACTION_RADIUS_M {
14101 continue;
14102 }
14103 if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
14104 best = Some((dist, d.id.clone(), d.locked));
14105 }
14106 }
14107 let Some((_, door_id, locked_now)) = best else {
14108 anyhow::bail!("no lockable door nearby");
14109 };
14110 let locked = !locked_now;
14111 self.seq += 1;
14112 self.session
14113 .submit_intent(Intent::SetDoorLocked {
14114 entity_id: self.state.entity_id,
14115 door_id,
14116 locked,
14117 seq: self.seq,
14118 })
14119 .await?;
14120 self.state.intents_sent += 1;
14121 Ok(())
14122 }
14123
14124 pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
14126 if !self.state.is_alive() {
14127 anyhow::bail!("you are dead");
14128 }
14129 if self.state.effective_inside_building().is_some() {
14130 anyhow::bail!("already inside");
14131 }
14132 let (px, py) = self.state.player_position();
14133 let mut best: Option<(f32, String)> = None;
14134 for d in &self.state.doors {
14135 if !d.open || d.locked {
14136 continue;
14137 }
14138 let player_house = self
14139 .state
14140 .buildings
14141 .iter()
14142 .find(|b| b.id == d.building_id)
14143 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
14144 if !player_house {
14145 continue;
14146 }
14147 let dist = (d.x - px).hypot(d.y - py);
14148 if dist > DOOR_INTERACTION_RADIUS_M {
14149 continue;
14150 }
14151 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
14152 best = Some((dist, d.id.clone()));
14153 }
14154 }
14155 let Some((_, door_id)) = best else {
14156 anyhow::bail!("no open house door nearby — open with f first");
14157 };
14158 self.seq += 1;
14159 self.session
14160 .submit_intent(Intent::EnterBuildingDoor {
14161 entity_id: self.state.entity_id,
14162 door_id,
14163 seq: self.seq,
14164 })
14165 .await?;
14166 self.state.intents_sent += 1;
14167 Ok(())
14168 }
14169
14170 pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
14173 if !self.state.is_alive() {
14174 anyhow::bail!("you are dead");
14175 }
14176 let Some(bid) = self.state.effective_inside_building() else {
14177 anyhow::bail!("not inside a building");
14178 };
14179 let (px, py) = self.state.player_position();
14180 let mut best: Option<(f32, String)> = None;
14181 for d in &self.state.doors {
14182 if d.building_id != bid || d.portal.is_none() {
14183 continue;
14184 }
14185 let player_house = self
14186 .state
14187 .buildings
14188 .iter()
14189 .find(|b| b.id == d.building_id)
14190 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
14191 if !player_house {
14192 continue;
14193 }
14194 let dist = (d.x - px).hypot(d.y - py);
14195 if dist > 1.5 {
14196 continue;
14197 }
14198 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
14199 best = Some((dist, d.id.clone()));
14200 }
14201 }
14202 let Some((_, door_id)) = best else {
14203 anyhow::bail!("stand by the door to exit");
14204 };
14205 self.seq += 1;
14206 self.session
14207 .submit_intent(Intent::ExitBuildingDoor {
14208 entity_id: self.state.entity_id,
14209 door_id,
14210 seq: self.seq,
14211 })
14212 .await?;
14213 self.state.intents_sent += 1;
14214 Ok(())
14215 }
14216
14217 pub async fn confirm_interior_edit(
14219 &mut self,
14220 building_id: String,
14221 rooms: Vec<flatland_protocol::InteriorRoomEdit>,
14222 room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
14223 ) -> anyhow::Result<()> {
14224 self.seq += 1;
14225 self.session
14226 .submit_intent(Intent::ConfirmInteriorEdit {
14227 entity_id: self.state.entity_id,
14228 building_id,
14229 rooms,
14230 room_doors,
14231 seq: self.seq,
14232 })
14233 .await?;
14234 self.state.intents_sent += 1;
14235 Ok(())
14236 }
14237
14238 pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
14239 if !self.state.is_alive() {
14240 anyhow::bail!("you are dead");
14241 }
14242 self.seq += 1;
14243 self.session
14244 .submit_intent(Intent::Cultivate {
14245 entity_id: self.state.entity_id,
14246 x,
14247 y,
14248 seq: self.seq,
14249 })
14250 .await?;
14251 self.state.intents_sent += 1;
14252 Ok(())
14253 }
14254
14255 pub async fn plant_seeds(
14256 &mut self,
14257 seed_template_id: String,
14258 quantity: u32,
14259 ) -> anyhow::Result<()> {
14260 if !self.state.is_alive() {
14261 anyhow::bail!("you are dead");
14262 }
14263 self.seq += 1;
14264 self.session
14265 .submit_intent(Intent::PlantSeeds {
14266 entity_id: self.state.entity_id,
14267 seed_template_id: seed_template_id.clone(),
14268 quantity,
14269 seq: self.seq,
14270 })
14271 .await?;
14272 self.state.intents_sent += 1;
14273 self.state
14274 .push_log(format!("Planting {quantity}× {seed_template_id}…"));
14275 Ok(())
14276 }
14277
14278 pub fn open_plant_menu(&mut self) {
14279 if self.state.farm_seed_entries().is_empty() {
14280 self.state.push_log("No seeds in inventory to plant");
14281 return;
14282 }
14283 self.state.show_plant_menu = true;
14284 self.state.plant_menu_index = 0;
14285 self.state.plant_quantity = 1;
14286 self.state.clamp_plant_menu();
14287 }
14288
14289 pub fn close_plant_menu(&mut self) {
14290 self.state.show_plant_menu = false;
14291 }
14292
14293 pub fn plant_menu_move(&mut self, delta: i32) {
14294 let n = self.state.farm_seed_entries().len();
14295 if n == 0 {
14296 return;
14297 }
14298 let idx = self.state.plant_menu_index as i32 + delta;
14299 self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
14300 self.state.clamp_plant_menu();
14301 }
14302
14303 pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
14304 let next = self.state.plant_quantity as i32 + delta;
14305 self.state.plant_quantity = next.max(1) as u32;
14306 self.state.clamp_plant_menu();
14307 }
14308
14309 pub fn plant_menu_set_quantity_max(&mut self) {
14310 if let Some((_, max, _)) = self.state.plant_menu_selection() {
14311 self.state.plant_quantity = max;
14312 }
14313 self.state.clamp_plant_menu();
14314 }
14315
14316 pub fn plant_menu_set_quantity_min(&mut self) {
14317 self.state.plant_quantity = 1;
14318 self.state.clamp_plant_menu();
14319 }
14320
14321 pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
14322 let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
14323 self.close_plant_menu();
14324 anyhow::bail!("no seeds to plant");
14325 };
14326 self.close_plant_menu();
14327 self.plant_seeds(seed, qty).await?;
14328 self.state.push_log(format!("Planted {qty}× {label}"));
14329 Ok(())
14330 }
14331
14332 pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
14335 if !self.state.is_alive() {
14336 anyhow::bail!("you are dead");
14337 }
14338 let binding = self
14339 .state
14340 .hotbar_ability(slot)
14341 .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
14342 .to_string();
14343 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
14344 let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
14345 if qty == 0 {
14346 anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
14347 }
14348 return self.use_item(template_id).await;
14349 }
14350 let ability_id = binding;
14351 if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
14352 return self
14353 .cast_ability(&ability_id, Some(self.state.entity_id))
14354 .await;
14355 }
14356 let is_heal = ability_id == "heal_touch"
14357 || self
14358 .state
14359 .ability_meta
14360 .get(&ability_id)
14361 .map(|meta| meta.is_heal)
14362 .unwrap_or(false);
14363 let target = if is_heal {
14364 Some(
14365 self.state
14366 .target_for_slot(2)
14367 .unwrap_or(self.state.entity_id),
14368 )
14369 } else {
14370 self.state
14371 .target_for_slot(1)
14372 .or_else(|| self.state.target_for_slot(2))
14373 };
14374 let Some(target_id) = target else {
14375 anyhow::bail!("no target — Tab to select, then press the hotbar key");
14376 };
14377 self.cast_ability(&ability_id, Some(target_id)).await
14378 }
14379
14380 pub async fn set_hotbar_slot(
14383 &mut self,
14384 slot: u8,
14385 ability_id: Option<&str>,
14386 ) -> anyhow::Result<()> {
14387 if !self.state.is_alive() {
14388 anyhow::bail!("you are dead");
14389 }
14390 if !(1..=9).contains(&slot) {
14391 anyhow::bail!("hotbar slot must be 1–9");
14392 }
14393 let ability_id = ability_id
14394 .map(str::trim)
14395 .filter(|id| !id.is_empty())
14396 .map(str::to_string);
14397 self.seq += 1;
14398 self.session
14399 .submit_intent(Intent::SetHotbarSlot {
14400 entity_id: self.state.entity_id,
14401 slot,
14402 ability_id: ability_id.clone(),
14403 seq: self.seq,
14404 })
14405 .await?;
14406 self.state.intents_sent += 1;
14407 let idx = (slot - 1) as usize;
14408 if self.state.hotbar.len() < 9 {
14409 self.state.hotbar.resize(9, None);
14410 }
14411 if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
14412 *slot_mut = ability_id.clone();
14413 }
14414 match ability_id {
14415 Some(id) => {
14416 let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
14417 format!("use {tid}")
14418 } else {
14419 id
14420 };
14421 self.state.push_log(format!("Hotbar {slot} → {label}"))
14422 }
14423 None => self.state.push_log(format!("Hotbar {slot} cleared")),
14424 }
14425 Ok(())
14426 }
14427
14428 pub fn npc_verb_options(&self) -> Vec<&'static str> {
14429 self.state.npc_verb_options()
14430 }
14431
14432 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
14433 let Some(npc_id) = self.state.npc_verb_target.clone() else {
14434 return Ok(());
14435 };
14436 let options = self.npc_verb_options();
14437 let choice = options
14438 .get(self.state.npc_verb_index)
14439 .copied()
14440 .unwrap_or("Talk");
14441 match choice {
14442 "Turn in" => {
14443 self.submit_npc_quest_turn_in(&npc_id).await?;
14444 self.state.show_npc_verb_menu = false;
14445 }
14446 "Trade" | "Bank" | "Storage" | "Market" => {
14447 self.seq += 1;
14448 self.session
14449 .submit_intent(Intent::Interact {
14450 entity_id: self.state.entity_id,
14451 target_id: npc_id,
14452 seq: self.seq,
14453 })
14454 .await?;
14455 self.state.intents_sent += 1;
14456 }
14457 _ => {
14458 self.seq += 1;
14459 self.session
14460 .submit_intent(Intent::NpcTalkOpen {
14461 entity_id: self.state.entity_id,
14462 npc_id,
14463 seq: self.seq,
14464 })
14465 .await?;
14466 self.state.intents_sent += 1;
14467 }
14468 }
14469 Ok(())
14470 }
14471
14472 async fn submit_npc_quest_turn_in(&mut self, npc_id: &str) -> anyhow::Result<()> {
14473 let pending: Vec<(String, u32, String)> = self
14474 .state
14475 .quest_log
14476 .iter()
14477 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
14478 .flat_map(|q| q.objectives.iter())
14479 .filter(|o| !o.done && o.kind == "give_item" && o.npc_ref.as_deref() == Some(npc_id))
14480 .filter_map(|o| {
14481 let template = o.item_template.clone()?;
14482 let remaining = o.required.saturating_sub(o.current);
14483 if remaining == 0 {
14484 return None;
14485 }
14486 Some((template, remaining, o.label.clone()))
14487 })
14488 .collect();
14489 if pending.is_empty() {
14490 self.state.push_log("Nothing to turn in here.");
14491 return Ok(());
14492 }
14493 let mut sent = 0u32;
14494 for (template, remaining, label) in pending {
14495 let held = self.state.count_inventory_template(&template);
14496 let qty = remaining.min(held);
14497 if qty == 0 {
14498 self.state.push_log(format!("Need {label}"));
14499 continue;
14500 }
14501 self.seq += 1;
14502 self.session
14503 .submit_intent(Intent::QuestGiveItem {
14504 entity_id: self.state.entity_id,
14505 npc_id: npc_id.to_string(),
14506 template_id: template,
14507 quantity: qty,
14508 seq: self.seq,
14509 })
14510 .await?;
14511 self.state.intents_sent += 1;
14512 sent += 1;
14513 }
14514 if sent > 0 {
14515 self.state.push_log("Turning in quest items.");
14516 }
14517 Ok(())
14518 }
14519
14520 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
14521 let Some(chat) = self.state.npc_chat.clone() else {
14522 return Ok(());
14523 };
14524 let message = chat.input.trim().to_string();
14525 if message.is_empty() || chat.pending {
14526 return Ok(());
14527 }
14528 if let Some(c) = self.state.npc_chat.as_mut() {
14529 c.lines.push(format!("You: {message}"));
14530 c.input.clear();
14531 c.pending = true;
14532 }
14533 self.seq += 1;
14534 self.session
14535 .submit_intent(Intent::NpcTalkSay {
14536 entity_id: self.state.entity_id,
14537 npc_id: chat.npc_id,
14538 message,
14539 seq: self.seq,
14540 })
14541 .await?;
14542 self.state.intents_sent += 1;
14543 Ok(())
14544 }
14545
14546 pub async fn npc_talk_topic(&mut self, index: usize) -> anyhow::Result<()> {
14547 let topic = self
14548 .state
14549 .npc_chat
14550 .as_ref()
14551 .and_then(|c| c.suggested_topics.get(index))
14552 .cloned();
14553 let Some(topic) = topic else {
14554 return Ok(());
14555 };
14556 if let Some(c) = self.state.npc_chat.as_mut() {
14557 if c.pending {
14558 return Ok(());
14559 }
14560 c.input = topic;
14561 }
14562 self.npc_talk_send().await
14563 }
14564
14565 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
14566 let return_to_verbs = self.state.npc_verb_target.is_some();
14567 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
14568 self.state.show_npc_chat = false;
14569 if return_to_verbs {
14570 self.state.show_npc_verb_menu = true;
14571 }
14572 return Ok(());
14573 };
14574 self.seq += 1;
14575 self.session
14576 .submit_intent(Intent::NpcTalkClose {
14577 entity_id: self.state.entity_id,
14578 npc_id,
14579 seq: self.seq,
14580 })
14581 .await?;
14582 self.state.intents_sent += 1;
14583 self.state.show_npc_chat = false;
14584 self.state.npc_chat = None;
14585 if return_to_verbs {
14586 self.state.show_npc_verb_menu = true;
14587 }
14588 Ok(())
14589 }
14590
14591 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
14593 if self.state.show_quest_offer
14594 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
14595 {
14596 self.quest_offer_decline();
14597 return Ok(());
14598 }
14599 if self.state.show_npc_chat {
14600 return self.npc_talk_close().await;
14601 }
14602 if self.state.show_shop_menu {
14603 return self.back_from_shop_menu().await;
14604 }
14605 if self.state.bank_panel.is_some() {
14606 if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
14607 self.bank_transfer_back();
14608 return Ok(());
14609 }
14610 return self.close_bank_panel().await;
14611 }
14612 if self.state.storage_panel.is_some() {
14613 if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
14614 self.storage_ui_back();
14615 return Ok(());
14616 }
14617 return self.close_storage_panel().await;
14618 }
14619 if self.state.market_panel.is_some() {
14620 if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
14621 self.market_ui_back();
14622 return Ok(());
14623 }
14624 if self.state.market_buy_confirm.is_some() {
14625 self.state.market_buy_confirm = None;
14626 return Ok(());
14627 }
14628 return self.close_market_panel().await;
14629 }
14630 if self.state.show_npc_verb_menu {
14631 self.state.show_npc_verb_menu = false;
14632 self.state.npc_verb_target = None;
14633 }
14634 Ok(())
14635 }
14636
14637 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
14638 self.seq += 1;
14639 self.session
14640 .submit_intent(Intent::TestDamage {
14641 entity_id: self.state.entity_id,
14642 amount,
14643 seq: self.seq,
14644 })
14645 .await?;
14646 self.state.intents_sent += 1;
14647 Ok(())
14648 }
14649
14650 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
14651 self.cycle_combat_target_slot(1, reverse).await
14652 }
14653
14654 pub async fn cycle_combat_target_slot(
14655 &mut self,
14656 slot_index: u8,
14657 reverse: bool,
14658 ) -> anyhow::Result<()> {
14659 if !self.state.is_alive() {
14660 anyhow::bail!("you are dead");
14661 }
14662 let candidates = self.state.candidates_for_slot(slot_index);
14663 if candidates.is_empty() {
14664 anyhow::bail!("no targets nearby");
14665 }
14666 let current = self.state.target_for_slot(slot_index);
14667 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
14668 let next_idx = match idx {
14669 None => 0,
14670 Some(i) if reverse => {
14671 if i == 0 {
14672 candidates.len() - 1
14673 } else {
14674 i - 1
14675 }
14676 }
14677 Some(i) => (i + 1) % candidates.len(),
14678 };
14679 if idx == Some(next_idx) && candidates.len() == 1 {
14680 self.clear_combat_target_slot(slot_index).await?;
14681 return Ok(());
14682 }
14683 let (target_id, label) = candidates[next_idx].clone();
14684 self.set_combat_target_slot(slot_index, target_id, &label)
14685 .await
14686 }
14687
14688 pub async fn set_combat_target_slot(
14689 &mut self,
14690 slot_index: u8,
14691 target_id: EntityId,
14692 label: &str,
14693 ) -> anyhow::Result<()> {
14694 if !self.state.is_alive() {
14695 anyhow::bail!("you are dead");
14696 }
14697 self.seq += 1;
14698 self.session
14699 .submit_intent(Intent::SetTargetSlot {
14700 entity_id: self.state.entity_id,
14701 slot_index,
14702 target_id,
14703 seq: self.seq,
14704 })
14705 .await?;
14706 self.state.intents_sent += 1;
14707 if slot_index == 1 {
14708 self.state.combat_target = Some(target_id);
14709 self.state.combat_target_label = Some(label.to_string());
14710 }
14711 self.state
14712 .push_log(format!("Slot {slot_index} target: {label}"));
14713 Ok(())
14714 }
14715
14716 pub async fn set_combat_target(
14717 &mut self,
14718 target_id: EntityId,
14719 label: &str,
14720 ) -> anyhow::Result<()> {
14721 self.set_combat_target_slot(1, target_id, label).await
14722 }
14723
14724 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
14725 if slot_index == 1 && self.state.combat_target.is_none() {
14726 return Ok(());
14727 }
14728 self.seq += 1;
14729 self.session
14730 .submit_intent(Intent::ClearTargetSlot {
14731 entity_id: self.state.entity_id,
14732 slot_index,
14733 seq: self.seq,
14734 })
14735 .await?;
14736 if slot_index == 1 {
14737 self.state.combat_target = None;
14738 self.state.combat_target_label = None;
14739 }
14740 self.state.intents_sent += 1;
14741 self.state
14742 .push_log(format!("Slot {slot_index} target cleared"));
14743 Ok(())
14744 }
14745
14746 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
14747 self.clear_combat_target_slot(1).await
14748 }
14749
14750 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
14751 if !self.state.is_alive() {
14752 anyhow::bail!("you are dead");
14753 }
14754 self.seq += 1;
14755 self.session
14756 .submit_intent(Intent::AdvanceRotation {
14757 entity_id: self.state.entity_id,
14758 slot_index,
14759 seq: self.seq,
14760 })
14761 .await?;
14762 self.state.intents_sent += 1;
14763 Ok(())
14764 }
14765
14766 pub async fn assign_slot_preset(
14767 &mut self,
14768 slot_index: u8,
14769 preset_id: &str,
14770 ) -> anyhow::Result<()> {
14771 if !self.state.is_alive() {
14772 anyhow::bail!("you are dead");
14773 }
14774 self.seq += 1;
14775 self.session
14776 .submit_intent(Intent::AssignSlotPreset {
14777 entity_id: self.state.entity_id,
14778 slot_index,
14779 preset_id: preset_id.to_string(),
14780 seq: self.seq,
14781 })
14782 .await?;
14783 self.state.intents_sent += 1;
14784 if let Some(slot) = self
14785 .state
14786 .combat_slots
14787 .iter_mut()
14788 .find(|s| s.slot_index == slot_index)
14789 {
14790 slot.preset_id = Some(preset_id.to_string());
14791 if let Some(preset) = self
14792 .state
14793 .rotation_presets
14794 .iter()
14795 .find(|p| p.id == preset_id)
14796 {
14797 slot.preset_label = Some(preset.label.clone());
14798 slot.rotation = preset.abilities.clone();
14799 slot.rotation_index = 0;
14800 }
14801 }
14802 self.state
14803 .push_log(format!("T{slot_index} loadout → {preset_id}"));
14804 Ok(())
14805 }
14806
14807 pub async fn cast_ability(
14808 &mut self,
14809 ability_id: &str,
14810 target_id: Option<EntityId>,
14811 ) -> anyhow::Result<()> {
14812 if !self.state.is_alive() {
14813 anyhow::bail!("you are dead");
14814 }
14815 let allows_ground = self.state.ability_allows_ground(ability_id);
14816 let requires_ground = self.state.ability_requires_ground(ability_id);
14817 if requires_ground && self.state.ground_target.is_none() {
14818 anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
14819 }
14820 let (resolved_target_id, target_point) = if allows_ground {
14821 if let Some((x, y, z)) = self.state.ground_target {
14822 (
14823 target_id.unwrap_or(self.state.entity_id),
14824 Some(flatland_protocol::AimPoint { x, y, z }),
14825 )
14826 } else {
14827 (
14828 target_id
14829 .or_else(|| self.state.target_for_slot(2))
14830 .or_else(|| self.state.target_for_slot(1))
14831 .unwrap_or(self.state.entity_id),
14832 None,
14833 )
14834 }
14835 } else {
14836 (
14837 target_id
14838 .or_else(|| self.state.target_for_slot(2))
14839 .or_else(|| self.state.target_for_slot(1))
14840 .unwrap_or(self.state.entity_id),
14841 None,
14842 )
14843 };
14844 self.seq += 1;
14845 self.session
14846 .submit_intent(Intent::Cast {
14847 entity_id: self.state.entity_id,
14848 ability_id: ability_id.to_string(),
14849 target_id: resolved_target_id,
14850 target_point,
14851 seq: self.seq,
14852 })
14853 .await?;
14854 self.state.intents_sent += 1;
14855 match target_point {
14856 Some(point) => self.state.push_log(format!(
14857 "Cast {ability_id} → ({:.1}, {:.1})",
14858 point.x, point.y
14859 )),
14860 None => self
14861 .state
14862 .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
14863 }
14864 Ok(())
14865 }
14866
14867 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
14868 self.seq += 1;
14869 self.session
14870 .submit_intent(Intent::UpsertRotationPreset {
14871 entity_id: self.state.entity_id,
14872 preset: preset.clone(),
14873 seq: self.seq,
14874 })
14875 .await?;
14876 self.state.intents_sent += 1;
14877 if let Some(existing) = self
14878 .state
14879 .rotation_presets
14880 .iter_mut()
14881 .find(|p| p.id == preset.id)
14882 {
14883 *existing = preset.clone();
14884 } else {
14885 self.state.rotation_presets.push(preset.clone());
14886 }
14887 for slot in &mut self.state.combat_slots {
14888 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
14889 slot.preset_label = Some(preset.label.clone());
14890 slot.rotation = preset.abilities.clone();
14891 }
14892 }
14893 self.state
14894 .push_log(format!("Saved rotation: {}", preset.label));
14895 Ok(())
14896 }
14897
14898 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
14899 self.seq += 1;
14900 self.session
14901 .submit_intent(Intent::DeleteRotationPreset {
14902 entity_id: self.state.entity_id,
14903 preset_id: preset_id.to_string(),
14904 seq: self.seq,
14905 })
14906 .await?;
14907 self.state.intents_sent += 1;
14908 self.state.rotation_presets.retain(|p| p.id != preset_id);
14909 for slot in &mut self.state.combat_slots {
14910 if slot.preset_id.as_deref() == Some(preset_id) {
14911 slot.preset_id = None;
14912 slot.preset_label = None;
14913 slot.rotation.clear();
14914 slot.rotation_index = 0;
14915 }
14916 }
14917 self.state
14918 .push_log(format!("Deleted rotation: {preset_id}"));
14919 Ok(())
14920 }
14921
14922 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
14923 if !self.state.is_alive() {
14924 anyhow::bail!("you are dead");
14925 }
14926 let enabled = !self
14927 .state
14928 .combat_slots
14929 .iter()
14930 .find(|s| s.slot_index == slot_index)
14931 .map(|s| s.auto_enabled)
14932 .unwrap_or(false);
14933 self.seq += 1;
14934 self.session
14935 .submit_intent(Intent::SetAutoAttack {
14936 entity_id: self.state.entity_id,
14937 slot_index,
14938 enabled,
14939 seq: self.seq,
14940 })
14941 .await?;
14942 if slot_index == 1 {
14943 self.state.auto_attack = enabled;
14944 }
14945 self.state.intents_sent += 1;
14946 self.state.push_log(format!(
14947 "T{slot_index} auto {}",
14948 if enabled { "ON" } else { "OFF" }
14949 ));
14950 Ok(())
14951 }
14952
14953 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
14954 if !self.state.connected {
14955 anyhow::bail!("not connected");
14956 }
14957 if !self.state.is_alive() {
14958 anyhow::bail!("you are dead");
14959 }
14960 let (px, py) = self.state.player_position();
14961 if self
14962 .state
14963 .ground_drops
14964 .iter()
14965 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
14966 {
14967 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
14968 }
14969 self.seq += 1;
14970 self.session
14971 .submit_intent(Intent::Pickup {
14972 entity_id: self.state.entity_id,
14973 drop_id: None,
14974 seq: self.seq,
14975 })
14976 .await?;
14977 self.state.intents_sent += 1;
14978 self.state.push_audio(crate::social::AudioCue::LootPickup);
14979 Ok(())
14980 }
14981
14982 pub async fn dodge(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
14983 if !self.state.is_alive() {
14984 anyhow::bail!("you are dead");
14985 }
14986 self.seq += 1;
14988 self.session
14989 .submit_intent(Intent::Dodge {
14990 entity_id: self.state.entity_id,
14991 forward,
14992 strafe,
14993 seq: self.seq,
14994 })
14995 .await?;
14996 self.state.intents_sent += 1;
14997 self.state.push_log("Dodge!");
14998 self.state.push_audio(crate::social::AudioCue::CombatDodge);
14999 Ok(())
15000 }
15001
15002 pub async fn lunge(&mut self) -> anyhow::Result<()> {
15003 if !self.state.is_alive() {
15004 anyhow::bail!("you are dead");
15005 }
15006 let (forward, strafe) = self.last_move_axes();
15007 self.seq += 1;
15008 self.session
15009 .submit_intent(Intent::Lunge {
15010 entity_id: self.state.entity_id,
15011 forward,
15012 strafe,
15013 seq: self.seq,
15014 })
15015 .await?;
15016 self.state.intents_sent += 1;
15017 self.state.push_log("Lunge!");
15018 Ok(())
15019 }
15020
15021 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
15022 if !self.state.is_alive() {
15023 anyhow::bail!("you are dead");
15024 }
15025 self.seq += 1;
15026 self.session
15027 .submit_intent(Intent::DirectionalJump {
15028 entity_id: self.state.entity_id,
15029 forward,
15030 strafe,
15031 seq: self.seq,
15032 })
15033 .await?;
15034 self.state.intents_sent += 1;
15035 self.state.push_log("Jump!");
15036 Ok(())
15037 }
15038
15039 pub fn last_move_axes(&self) -> (f32, f32) {
15041 (self.last_move_forward, self.last_move_strafe)
15042 }
15043
15044 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
15045 if !self.state.is_alive() {
15046 anyhow::bail!("you are dead");
15047 }
15048 self.seq += 1;
15049 self.session
15050 .submit_intent(Intent::Block {
15051 entity_id: self.state.entity_id,
15052 enabled,
15053 seq: self.seq,
15054 })
15055 .await?;
15056 self.state.intents_sent += 1;
15057 if enabled {
15058 self.state.push_log("Blocking");
15059 self.state.push_audio(crate::social::AudioCue::CombatBlock);
15060 }
15061 Ok(())
15062 }
15063
15064 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
15065 if !self.state.is_alive() {
15066 anyhow::bail!("you are dead");
15067 }
15068 self.seq += 1;
15069 self.session
15070 .submit_intent(Intent::EquipMainhand {
15071 entity_id: self.state.entity_id,
15072 template_id,
15073 instance_id: None,
15074 seq: self.seq,
15075 })
15076 .await?;
15077 self.state.intents_sent += 1;
15078 Ok(())
15079 }
15080
15081 pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
15083 let idx = self.state.equip_menu_index;
15084 let slots = equip_paperdoll_rows(&self.state);
15085 let Some(row) = slots.get(idx) else {
15086 return Ok(());
15087 };
15088 match row {
15089 EquipPaperdollRow::Body { slot, filled } => {
15090 if *filled {
15091 self.equip_worn(*slot, None).await
15092 } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
15093 self.equip_worn(*slot, Some(inst)).await
15094 } else {
15095 self.state
15096 .push_log(format!("No item for {}", body_slot_label(*slot)));
15097 Ok(())
15098 }
15099 }
15100 EquipPaperdollRow::Mainhand { filled } => {
15101 if *filled {
15102 self.unequip_mainhand().await
15103 } else if let Some(tid) = first_inventory_weapon(&self.state) {
15104 self.equip_mainhand(Some(tid)).await
15105 } else {
15106 self.state.push_log("No weapon in inventory".to_string());
15107 Ok(())
15108 }
15109 }
15110 EquipPaperdollRow::Offhand { filled, locked } => {
15111 if *locked {
15112 self.state
15113 .push_log("Offhand locked — two-handed weapon equipped".to_string());
15114 Ok(())
15115 } else if *filled {
15116 self.unequip_offhand().await
15117 } else if let Some(tid) = first_inventory_offhand(&self.state) {
15118 self.equip_offhand(Some(tid)).await
15119 } else {
15120 self.state
15121 .push_log("No offhand item in inventory".to_string());
15122 Ok(())
15123 }
15124 }
15125 }
15126 }
15127
15128 pub async fn say(
15129 &mut self,
15130 channel: flatland_protocol::ChatChannel,
15131 text: &str,
15132 ) -> anyhow::Result<()> {
15133 self.say_to(channel, text, None).await
15134 }
15135
15136 pub async fn say_to(
15137 &mut self,
15138 channel: flatland_protocol::ChatChannel,
15139 text: &str,
15140 to_entity: Option<EntityId>,
15141 ) -> anyhow::Result<()> {
15142 self.seq += 1;
15143 self.session
15144 .submit_intent(Intent::Say {
15145 entity_id: self.state.entity_id,
15146 channel,
15147 text: text.to_string(),
15148 to_entity,
15149 seq: self.seq,
15150 })
15151 .await?;
15152 self.state.intents_sent += 1;
15153 Ok(())
15154 }
15155
15156 pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
15157 let Some(peer) = self.state.player_verbs.target_entity else {
15158 return Ok(());
15159 };
15160 let label = self.state.player_verbs.target_label.clone();
15161 let choice = crate::social::PlayerVerbState::options()
15162 .get(self.state.player_verbs.index)
15163 .copied()
15164 .unwrap_or("Whisper");
15165 self.state.player_verbs.close();
15166 match choice {
15167 "Trade" => {
15168 self.seq += 1;
15171 self.session
15172 .submit_intent(Intent::TradeRequest {
15173 entity_id: self.state.entity_id,
15174 peer_entity_id: peer,
15175 seq: self.seq,
15176 })
15177 .await?;
15178 self.state.intents_sent += 1;
15179 self.state.social_chat.push_system(format!(
15180 "Trade request sent to {label} — waiting for accept"
15181 ));
15182 }
15183 "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
15184 _ => self.state.social_chat.focus_nearby(),
15185 }
15186 Ok(())
15187 }
15188
15189 pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
15190 let Some(pending) = self.state.social_chat.pending_trade.take() else {
15191 return Ok(());
15192 };
15193 self.seq += 1;
15194 self.session
15195 .submit_intent(Intent::TradeRespond {
15196 entity_id: self.state.entity_id,
15197 peer_entity_id: pending.from_entity,
15198 accept,
15199 seq: self.seq,
15200 })
15201 .await?;
15202 self.state.intents_sent += 1;
15203 if accept {
15204 self.state
15205 .social_chat
15206 .push_system(format!("Accepted trade with {}", pending.from_name));
15207 } else {
15208 self.state
15209 .social_chat
15210 .push_system(format!("Declined trade with {}", pending.from_name));
15211 }
15212 Ok(())
15213 }
15214
15215 pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
15216 let text = self.state.social_chat.buffer.trim().to_string();
15217 if text.is_empty() {
15218 return Ok(());
15219 }
15220 self.state.social_chat.buffer.clear();
15221 if crate::social::is_chat_slash_line(&text) {
15222 match crate::social::parse_chat_slash(&text) {
15223 Some(cmd) => return self.apply_chat_slash(cmd).await,
15224 None => {
15225 self.state.social_chat.push_system(format!(
15226 "Unknown command — {}",
15227 crate::social::chat_slash_help_text()
15228 ));
15229 return Ok(());
15230 }
15231 }
15232 }
15233 let thread = self.state.social_chat.thread;
15234 let channel = thread.channel();
15235 let to = thread.to_entity();
15236 if let Some(peer) = to {
15237 let label = self.state.social_chat.peer_label.clone();
15238 self.state
15239 .social_chat
15240 .remember_whisper_peer(peer, &label, channel);
15241 }
15242 self.say_to(channel, &text, to).await
15243 }
15244
15245 async fn apply_chat_slash(
15246 &mut self,
15247 cmd: crate::social::ChatSlashCommand,
15248 ) -> anyhow::Result<()> {
15249 use crate::social::{chat_slash_help_text, ChatSlashCommand};
15250 match cmd {
15251 ChatSlashCommand::Help => {
15252 self.state
15253 .social_chat
15254 .push_system(chat_slash_help_text().to_string());
15255 Ok(())
15256 }
15257 ChatSlashCommand::Nearby { message } => {
15258 self.state.social_chat.focus_nearby();
15259 self.state
15260 .social_chat
15261 .push_system("Nearby speech — everyone close can hear");
15262 if let Some(msg) = message {
15263 self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
15264 .await
15265 } else {
15266 Ok(())
15267 }
15268 }
15269 ChatSlashCommand::Reply { message } => {
15270 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
15271 self.state
15272 .social_chat
15273 .push_system("No one to reply to — wait for a whisper, or /whisper Name");
15274 return Ok(());
15275 };
15276 let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
15277 self.state
15278 .social_chat
15279 .set_whisper_thread(peer.entity_id, &peer.label, stone);
15280 self.state.social_chat.push_system(format!(
15281 "Replying to {} — type and Enter · /nearby",
15282 peer.label
15283 ));
15284 if let Some(msg) = message {
15285 self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
15286 } else {
15287 Ok(())
15288 }
15289 }
15290 ChatSlashCommand::Whisper { name, message } => {
15291 let (peer_id, label, stone) = if let Some(name) = name {
15292 match self.resolve_whisper_target(&name) {
15293 Ok(t) => t,
15294 Err(err) => {
15295 self.state.social_chat.push_system(err);
15296 return Ok(());
15297 }
15298 }
15299 } else {
15300 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
15301 self.state.social_chat.push_system(
15302 "Usage: /whisper Name [message] · or /reply after someone whispers you",
15303 );
15304 return Ok(());
15305 };
15306 (
15307 peer.entity_id,
15308 peer.label,
15309 peer.channel == flatland_protocol::ChatChannel::WhisperStone,
15310 )
15311 };
15312 self.state
15313 .social_chat
15314 .set_whisper_thread(peer_id, &label, stone);
15315 let channel = if stone {
15316 flatland_protocol::ChatChannel::WhisperStone
15317 } else {
15318 flatland_protocol::ChatChannel::Whisper
15319 };
15320 if let Some(msg) = message {
15321 self.state
15322 .social_chat
15323 .push_system(format!("Whisper → {label}"));
15324 self.say_to(channel, &msg, Some(peer_id)).await
15325 } else {
15326 self.state.social_chat.push_system(format!(
15327 "Whispering {label} — type and Enter · Esc / /nearby cancels"
15328 ));
15329 Ok(())
15330 }
15331 }
15332 }
15333 }
15334
15335 fn resolve_whisper_target(&self, name: &str) -> Result<(EntityId, String, bool), String> {
15337 let needle = name.trim().to_ascii_lowercase();
15338 if needle.is_empty() {
15339 return Err("Usage: /whisper Name [message]".into());
15340 }
15341 let mut candidates: Vec<(EntityId, String)> = self
15342 .state
15343 .entities
15344 .iter()
15345 .filter(|e| e.id != self.state.entity_id)
15346 .filter(|e| !e.label.trim().is_empty())
15347 .filter(|e| e.vitals.is_some())
15348 .filter(|e| !self.state.npcs.iter().any(|n| n.id == e.id.to_string()))
15349 .filter(|e| !self.state.hired_workers.iter().any(|w| w.entity_id == e.id))
15350 .map(|e| (e.id, e.label.clone()))
15351 .collect();
15352
15353 if let Some(last) = &self.state.social_chat.last_whisper_peer {
15355 if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
15356 candidates.push((last.entity_id, last.label.clone()));
15357 }
15358 }
15359
15360 let exact: Vec<_> = candidates
15361 .iter()
15362 .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
15363 .cloned()
15364 .collect();
15365 let pool = if exact.len() == 1 {
15366 exact
15367 } else if exact.len() > 1 {
15368 return Err(format!(
15369 "Several players named '{name}' nearby — move closer and try again"
15370 ));
15371 } else {
15372 let starts: Vec<_> = candidates
15373 .iter()
15374 .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
15375 .cloned()
15376 .collect();
15377 if starts.len() == 1 {
15378 starts
15379 } else if starts.len() > 1 {
15380 let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
15381 return Err(format!(
15382 "Ambiguous name '{name}' — matches: {}",
15383 names.join(", ")
15384 ));
15385 } else {
15386 let contains: Vec<_> = candidates
15387 .iter()
15388 .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
15389 .cloned()
15390 .collect();
15391 if contains.len() == 1 {
15392 contains
15393 } else if contains.is_empty() {
15394 return Err(format!(
15395 "No player matching '{name}' in range — get closer or check the spelling"
15396 ));
15397 } else {
15398 let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
15399 return Err(format!(
15400 "Ambiguous name '{name}' — matches: {}",
15401 names.join(", ")
15402 ));
15403 }
15404 }
15405 };
15406
15407 let (id, label) = pool.into_iter().next().unwrap();
15408 let stone = self
15409 .state
15410 .social_chat
15411 .last_whisper_peer
15412 .as_ref()
15413 .is_some_and(|p| {
15414 p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone
15415 });
15416 Ok((id, label, stone))
15417 }
15418
15419 pub async fn trade_present_selected(
15420 &mut self,
15421 item_instance_id: uuid::Uuid,
15422 ) -> anyhow::Result<()> {
15423 self.trade_present_quantity(item_instance_id, None).await
15424 }
15425
15426 pub async fn trade_present_quantity(
15427 &mut self,
15428 item_instance_id: uuid::Uuid,
15429 quantity: Option<u32>,
15430 ) -> anyhow::Result<()> {
15431 self.seq += 1;
15432 self.session
15433 .submit_intent(Intent::TradePresent {
15434 entity_id: self.state.entity_id,
15435 item_instance_id,
15436 quantity,
15437 seq: self.seq,
15438 })
15439 .await?;
15440 self.state.intents_sent += 1;
15441 self.state.trade_ui.qty_entry = None;
15442 self.state.trade_ui.picking_inventory = false;
15443 Ok(())
15444 }
15445
15446 pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
15448 if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
15449 let qty = self.state.trade_ui.present_quantity();
15450 return self
15451 .trade_present_quantity(entry.item_instance_id, qty)
15452 .await;
15453 }
15454 if !self.state.trade_ui.picking_inventory {
15455 return Ok(());
15456 }
15457 let stacks = self.state.trade_presentable_stacks();
15458 let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
15459 return Ok(());
15460 };
15461 let Some(id) = stack.item_instance_id else {
15462 return Ok(());
15463 };
15464 let label = stack
15465 .display_name
15466 .clone()
15467 .unwrap_or_else(|| stack.template_id.clone());
15468 if stack.quantity <= 1 {
15469 self.trade_present_quantity(id, Some(1)).await
15470 } else {
15471 self.state
15472 .trade_ui
15473 .begin_qty_entry(id, label, stack.quantity);
15474 Ok(())
15475 }
15476 }
15477
15478 pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
15479 self.seq += 1;
15480 self.session
15481 .submit_intent(Intent::TradeSetReady {
15482 entity_id: self.state.entity_id,
15483 ready,
15484 seq: self.seq,
15485 })
15486 .await?;
15487 self.state.intents_sent += 1;
15488 Ok(())
15489 }
15490
15491 pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
15492 self.seq += 1;
15493 self.session
15494 .submit_intent(Intent::TradeCancel {
15495 entity_id: self.state.entity_id,
15496 seq: self.seq,
15497 })
15498 .await?;
15499 self.state.intents_sent += 1;
15500 self.state.trade_ui.close();
15501 Ok(())
15502 }
15503
15504 pub async fn destroy_whisper_stone(
15505 &mut self,
15506 item_instance_id: uuid::Uuid,
15507 ) -> anyhow::Result<()> {
15508 self.seq += 1;
15509 self.session
15510 .submit_intent(Intent::DestroyWhisperStone {
15511 entity_id: self.state.entity_id,
15512 item_instance_id,
15513 seq: self.seq,
15514 })
15515 .await?;
15516 self.state.intents_sent += 1;
15517 Ok(())
15518 }
15519
15520 pub async fn stop(&mut self) -> anyhow::Result<()> {
15521 self.seq += 1;
15522 self.session
15523 .submit_intent(Intent::Stop {
15524 entity_id: self.state.entity_id,
15525 seq: self.seq,
15526 })
15527 .await?;
15528 self.state.intents_sent += 1;
15529 Ok(())
15530 }
15531
15532 pub fn disconnect(&self) {
15533 self.session.disconnect();
15534 }
15535}
15536
15537fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
15538 let dx = ax - bx;
15539 let dy = ay - by;
15540 (dx * dx + dy * dy).sqrt()
15541}
15542
15543#[cfg(test)]
15544mod tests {
15545 use std::collections::BTreeMap;
15546
15547 use super::*;
15548 use flatland_protocol::{
15549 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
15550 };
15551
15552 fn sample_state() -> GameState {
15553 let mut state = GameState {
15554 session_id: 1,
15555 entity_id: 1,
15556 character_id: None,
15557 tick: 0,
15558 chunk_rev: 0,
15559 content_rev: 0,
15560 publish_rev: 0,
15561 entities: vec![EntityState {
15562 id: 1,
15563 label: "You".into(),
15564 transform: Transform {
15565 position: WorldCoord::surface(128.0, 128.0),
15566 yaw: 0.0,
15567 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
15568 },
15569 vitals: None,
15570 attributes: None,
15571 skills: None,
15572 inside_building: None,
15573 tile_id: None,
15574 paperdoll_ref: None,
15575 draw_scale: 1.0,
15576 presentation_state: None,
15577 sprite_mode: None,
15578 progression_xp: None,
15579 combat_cues: vec![],
15580 statuses: vec![],
15581 }],
15582 player: None,
15583 resource_nodes: vec![ResourceNodeView {
15584 id: "oak-1".into(),
15585 label: "Oak".into(),
15586 x: 130.0,
15587 y: 128.0,
15588 z: 0.0,
15589 item_template: "oak_log".into(),
15590 state: ResourceNodeState::Available,
15591 blocking: true,
15592 blocking_radius_m: 0.8,
15593 harvest_off: false,
15594 tile_id: None,
15595 yaw: 0.0,
15596 pitch: 0.0,
15597 roll: 0.0,
15598 draw_scale: 1.0,
15599 sprite_mode: None,
15600 growth_progress: None,
15601 presentation_state: None,
15602 channel_start_tick: None,
15603 channel_end_tick: None,
15604 harvest_drop_templates: vec![],
15605 }],
15606 ground_drops: vec![],
15607 placed_containers: vec![],
15608 buildings: vec![BuildingView {
15609 id: "broker-hut".into(),
15610 label: "Broker".into(),
15611 x: 148.0,
15612 y: 118.0,
15613 width_m: 8.0,
15614 depth_m: 6.0,
15615 interior_blueprint: Some("broker_hut".into()),
15616 tags: vec![],
15617 market_boundary_zone_ids: vec![],
15618 market_max_volume: None,
15619 wall_set: None,
15620 roof_set: None,
15621 }],
15622 doors: vec![flatland_protocol::DoorView {
15623 id: "door-1".into(),
15624 building_id: "broker-hut".into(),
15625 x: 148.0,
15626 y: 118.0,
15627 open: false,
15628 portal: Some("front".into()),
15629 locked: false,
15630 accessible: true,
15631 lock_id: None,
15632 }],
15633 interior_map: None,
15634 npcs: vec![],
15635 blueprints: vec![],
15636 building_materials: vec![],
15637 world_x0: 0.0,
15638 world_y0: 0.0,
15639 world_width_m: 256.0,
15640 world_height_m: 256.0,
15641 terrain_zones: Vec::new(),
15642 z_platforms: Vec::new(),
15643 z_transitions: Vec::new(),
15644 z_bands_outdoor_backup: None,
15645 world_clock: flatland_protocol::WorldClock::default(),
15646 inventory: std::collections::HashMap::new(),
15647 inventory_hints: std::collections::HashMap::new(),
15648 logs: VecDeque::new(),
15649 intents_sent: 0,
15650 ticks_received: 0,
15651 connected: true,
15652 disconnect_reason: None,
15653 show_stats: false,
15654 hud_log_hidden: false,
15655 show_equip_menu: false,
15656 equip_menu_index: 0,
15657 show_craft_menu: false,
15658 show_plot_build_menu: false,
15659 plot_build_focus_wall: true,
15660 plot_build_wall_index: 0,
15661 plot_build_roof_index: 0,
15662 craft_menu_index: 0,
15663 craft_batch_quantity: 1,
15664 show_shop_menu: false,
15665 shop_catalog: None,
15666 bank_panel: None,
15667 bank_menu_index: 0,
15668 bank_ui_mode: BankUiMode::Menu,
15669 storage_panel: None,
15670 market_panel: None,
15671 market_menu_index: 0,
15672 market_filter: String::new(),
15673 market_filter_focused: false,
15674 market_category_filter: None,
15675 market_buy_confirm: None,
15676 market_ui_mode: MarketUiMode::Browse,
15677 storage_menu_index: 0,
15678 storage_ui_mode: StorageUiMode::Menu,
15679 shop_tab: ShopTab::default(),
15680 shop_menu_index: 0,
15681 shop_quantity: 1,
15682 shop_trade_log: VecDeque::new(),
15683 show_npc_verb_menu: false,
15684 npc_verb_target: None,
15685 npc_verb_index: 0,
15686 player_verbs: crate::social::PlayerVerbState::default(),
15687 social_chat: crate::social::SocialChatState::default(),
15688 trade_ui: crate::social::TradeUiState::default(),
15689 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
15690 show_npc_chat: false,
15691 npc_chat: None,
15692 show_inventory_menu: false,
15693 inventory_menu_index: 0,
15694 inventory_tab: InventoryTab::OnPerson,
15695 inventory_filter: String::new(),
15696 inventory_filter_focused: false,
15697 show_move_picker: false,
15698 show_rename_prompt: false,
15699 rename_plot_id: None,
15700 highlighted_plot_id: None,
15701 show_worker_rename: false,
15702 rename_buffer: String::new(),
15703 move_picker_index: 0,
15704 move_picker: None,
15705 show_grant_picker: false,
15706 grant_picker_index: 0,
15707 grant_picker: None,
15708 show_destroy_picker: false,
15709 destroy_confirm_pending: false,
15710 destroy_picker: None,
15711 combat_target: None,
15712 combat_target_label: None,
15713 ground_target: None,
15714 combat_fx: Vec::new(),
15715 ground_hazards: Vec::new(),
15716 property_zones: Vec::new(),
15717 tax_zones: Vec::new(),
15718 growth_zones: Vec::new(),
15719 biome_zones: Vec::new(),
15720 terrain_kind_nav: Vec::new(),
15721 property_plots: Vec::new(),
15722 property_plot_settings: None,
15723 claim_mode: None,
15724 relocate_mode: None,
15725 sell_plot_confirm: None,
15726 sell_plot_armed_at: None,
15727 show_plant_menu: false,
15728 plant_menu_index: 0,
15729 show_farm_access: false,
15730 farm_access_name_draft: String::new(),
15731 farm_access_discount_bps: 0,
15732 farm_access_index: 0,
15733 plant_quantity: 1,
15734 in_combat: false,
15735 auto_attack: true,
15736 combat_has_los: false,
15737 attack_cd_ticks: 0,
15738 gcd_ticks: 0,
15739 weapon_ability_id: "unarmed".into(),
15740 mainhand_template_id: None,
15741 mainhand_label: None,
15742 mainhand_instance_id: None,
15743 offhand_template_id: None,
15744 offhand_label: None,
15745 offhand_instance_id: None,
15746 mainhand_hand_slots: 1,
15747 defense: None,
15748 worn: BTreeMap::new(),
15749 carry_mass: 0.0,
15750 carry_mass_max: 0.0,
15751 encumbrance: flatland_protocol::EncumbranceState::Light,
15752 inventory_stacks: Vec::new(),
15753 keychain_stacks: Vec::new(),
15754 whisper_pouch_stacks: Vec::new(),
15755 combat_target_detail: None,
15756 statuses: Vec::new(),
15757 cast_progress: None,
15758 timed_channel: None,
15759 plot_build_offer: None,
15760 ability_cooldowns: Vec::new(),
15761 blocking_active: false,
15762 max_target_slots: 1,
15763 combat_slots: Vec::new(),
15764 rotation_presets: Vec::new(),
15765 known_abilities: Vec::new(),
15766 ability_meta: std::collections::HashMap::new(),
15767 ability_mastery: std::collections::HashMap::new(),
15768 hotbar: vec![None; 9],
15769 max_abilities_per_rotation: 0,
15770 show_loadout_menu: false,
15771 show_keychain_menu: false,
15772 keychain_menu_index: 0,
15773 show_rotation_editor: false,
15774 loadout_menu_index: 0,
15775 loadout_hotbar_slot: 1,
15776 loadout_ability_index: 0,
15777 loadout_focus_presets: false,
15778 rotation_editor: RotationEditorState::default(),
15779 harvest_in_progress: false,
15780 harvest_started_at: None,
15781 pending_craft_ack: None,
15782 pending_worker_job_ack: None,
15783 attending_worker_instance_id: None,
15784 quest_log: Vec::new(),
15785 interactables: Vec::new(),
15786 ledger: None,
15787 career: None,
15788 character_sheet_tab: CharacterSheetTab::Character,
15789 ledger_period: LedgerPeriod::Day,
15790 show_quest_offer: false,
15791 pending_quest_offer: None,
15792 show_quest_menu: false,
15793 quest_menu_index: 0,
15794 quest_withdraw_confirm: false,
15795 hired_workers: Vec::new(),
15796 show_workers_menu: false,
15797 workers_menu_index: 0,
15798 worker_dismiss_confirmation: None,
15799 workers_menu_compact: false,
15800 worker_step_display: BTreeMap::new(),
15801 worker_error_display: BTreeMap::new(),
15802 worker_health_ring_until: BTreeMap::new(),
15803 pending_worker_hire_since: None,
15804 show_worker_give_picker: false,
15805 worker_give_picker_index: 0,
15806 worker_give_picker: None,
15807 show_worker_give_target_picker: false,
15808 worker_give_target_picker_index: 0,
15809 worker_give_target_picker: None,
15810 show_worker_take_picker: false,
15811 worker_take_picker_index: 0,
15812 worker_take_picker: None,
15813 show_worker_teach_picker: false,
15814 worker_teach_picker_index: 0,
15815 worker_teach_picker: None,
15816 worker_route_editor: None,
15817 progression_curve: None,
15818 };
15819 state.player = state.entities.first().cloned();
15820 state
15821 }
15822
15823 #[test]
15824 fn whisper_cancels_when_peer_walks_out_of_range() {
15825 let mut state = sample_state();
15826 state.player = state.entities.first().cloned();
15827 let mut peer = state.entities[0].clone();
15828 peer.id = 2;
15829 peer.label = "Ada".into();
15830 peer.transform.position = WorldCoord::surface(129.0, 128.0); state.entities.push(peer.clone());
15832 state.social_chat.focus_whisper(2, "Ada");
15833 state.refresh_whisper_range();
15834 assert!(matches!(
15835 state.social_chat.thread,
15836 crate::social::ChatThreadKind::Whisper { peer: 2 }
15837 ));
15838
15839 peer.transform.position = WorldCoord::surface(132.0, 128.0); state.entities[1] = peer;
15841 state.refresh_whisper_range();
15842 assert_eq!(
15843 state.social_chat.thread,
15844 crate::social::ChatThreadKind::Nearby
15845 );
15846 assert!(!state.social_chat.input_focused);
15847 }
15848
15849 #[test]
15850 fn probe_use_world_hired_worker_manage() {
15851 let mut state = sample_state();
15852 state
15853 .hired_workers
15854 .push(flatland_protocol::HiredWorkerView {
15855 instance_id: "worker-1".into(),
15856 entity_id: 42,
15857 def_id: "worker_laborer".into(),
15858 label: "Sam".into(),
15859 x: 129.0,
15860 y: 128.0,
15861 z: 0.0,
15862 mode: flatland_protocol::WorkerModeView::JobLoop,
15863 state: flatland_protocol::WorkerStateView::Working,
15864 step_label: "cultivate".into(),
15865 vitals: flatland_protocol::WorkerVitalsSummary {
15866 health_pct: 100.0,
15867 stamina_pct: 100.0,
15868 mana_pct: 100.0,
15869 hunger_pct: 100.0,
15870 thirst_pct: 100.0,
15871 },
15872 carry_pct: 0.0,
15873 last_error: None,
15874 wage_copper_per_interval: 1,
15875 effective_wage_copper: 1,
15876 wage_meters_walked: 0.0,
15877 lodging_container_id: None,
15878 route: None,
15879 route_stop_index: None,
15880 known_blueprint_ids: Vec::new(),
15881 level: 1,
15882 worker_xp: 0.0,
15883 inventory: Vec::new(),
15884 equipment: flatland_protocol::WorkerEquipmentView::default(),
15885 issue_hint: None,
15886 });
15887 let probe = state.probe_use_world();
15888 let primary = probe.primary.expect("primary");
15889 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
15890 assert_eq!(primary.id, "worker-1");
15891 assert!(primary.hint_line().contains("Manage"));
15892 assert!(primary.hint_line().contains("Sam"));
15893 assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
15894 }
15895
15896 #[test]
15897 fn market_clerk_verb_options_include_market() {
15898 let mut state = sample_state();
15899 state.npcs.push(flatland_protocol::NpcView {
15900 id: "mira_market".into(),
15901 label: "Mira".into(),
15902 role: "market_clerk".into(),
15903 x: 129.0,
15904 y: 128.0,
15905 building_id: Some("town_market".into()),
15906 entity_id: None,
15907 life_state: None,
15908 hp_pct: None,
15909 can_trade: false,
15910 buy_templates: vec![],
15911 tile_id: None,
15912 behavior_state: None,
15913 presentation_state: None,
15914 sprite_mode: None,
15915 paperdoll_ref: None,
15916 draw_scale: 1.0,
15917 yaw: None,
15918 perception_fov_deg: None,
15919 perception_sight_m: None,
15920 perception_hear_m: None,
15921 });
15922 state.npc_verb_target = Some("mira_market".into());
15923 assert_eq!(state.npc_verb_options(), vec!["Market", "Talk"]);
15924 }
15925
15926 #[test]
15927 fn butcher_verb_options_include_turn_in_for_give_item() {
15928 let mut state = sample_state();
15929 state.npcs.push(flatland_protocol::NpcView {
15930 id: "town_butcher_1".into(),
15931 label: "Brutus".into(),
15932 role: "butcher".into(),
15933 x: 129.0,
15934 y: 128.0,
15935 building_id: None,
15936 entity_id: None,
15937 life_state: None,
15938 hp_pct: None,
15939 can_trade: true,
15940 buy_templates: vec!["raw_venison".into()],
15941 tile_id: None,
15942 behavior_state: None,
15943 presentation_state: None,
15944 sprite_mode: None,
15945 paperdoll_ref: None,
15946 draw_scale: 1.0,
15947 yaw: None,
15948 perception_fov_deg: None,
15949 perception_sight_m: None,
15950 perception_hear_m: None,
15951 });
15952 state.quest_log.push(flatland_protocol::QuestLogEntry {
15953 quest_id: "deer_threat".into(),
15954 title: "Deer threat".into(),
15955 description: String::new(),
15956 status: flatland_protocol::QuestStatusView::Active,
15957 current_step_id: Some("deliver".into()),
15958 current_step_title: "Deliver venison".into(),
15959 objectives: vec![flatland_protocol::QuestObjectiveProgress {
15960 label: "Give 3 Raw venison to Brutus".into(),
15961 current: 0,
15962 required: 3,
15963 done: false,
15964 kind: "give_item".into(),
15965 npc_ref: Some("town_butcher_1".into()),
15966 item_template: Some("raw_venison".into()),
15967 blueprint_id: None,
15968 building_id: None,
15969 }],
15970 is_tracked: true,
15971 can_withdraw: true,
15972 });
15973 state.npc_verb_target = Some("town_butcher_1".into());
15974 assert_eq!(state.npc_verb_options(), vec!["Turn in", "Talk", "Trade"]);
15975 }
15976
15977 #[test]
15978 fn market_list_excludes_currency_stacks() {
15979 let mut state = sample_state();
15980 state.inventory_stacks = vec![
15981 flatland_protocol::ItemStack {
15982 template_id: "copper_coin".into(),
15983 quantity: 50,
15984 item_instance_id: Some(uuid::Uuid::from_u128(10)),
15985 display_name: Some("Copper Coin".into()),
15986 ..Default::default()
15987 },
15988 flatland_protocol::ItemStack {
15989 template_id: "oak_log".into(),
15990 quantity: 2,
15991 item_instance_id: Some(uuid::Uuid::from_u128(11)),
15992 display_name: Some("Oak Log".into()),
15993 ..Default::default()
15994 },
15995 flatland_protocol::ItemStack {
15996 template_id: "whisper_stone".into(),
15997 quantity: 1,
15998 item_instance_id: Some(uuid::Uuid::from_u128(12)),
15999 display_name: Some("Whisper Stone".into()),
16000 category: Some("quest".into()),
16001 listable: Some(false),
16002 ..Default::default()
16003 },
16004 ];
16005 let opts = state.market_list_item_options(&MarketListSourceKind::Person);
16006 assert_eq!(opts.len(), 1);
16007 assert!(opts[0].label.contains("Oak"));
16008 }
16009
16010 #[test]
16011 fn market_browse_filters_by_category_and_search() {
16012 let mut state = sample_state();
16013 state.market_panel = Some(flatland_protocol::MarketPanel {
16014 npc_id: "mira_market".into(),
16015 npc_label: "Mira".into(),
16016 building_id: "town_market".into(),
16017 building_label: "Town Market".into(),
16018 used_volume: 0.0,
16019 max_volume: 100.0,
16020 listings: vec![
16021 flatland_protocol::MarketListingView {
16022 listing_id: uuid::Uuid::from_u128(1),
16023 seller_character_id: uuid::Uuid::from_u128(2),
16024 seller_label: "Ada".into(),
16025 hall_building_id: "town_market".into(),
16026 hall_label: "Town Market".into(),
16027 template_id: "oak_log".into(),
16028 display_name: "Oak Log".into(),
16029 category: "resource".into(),
16030 quantity: 3,
16031 unit_price_copper: 10,
16032 line_total_copper: 30,
16033 npc_price: false,
16034 npc_dump_unit_copper: None,
16035 mine: false,
16036 },
16037 flatland_protocol::MarketListingView {
16038 listing_id: uuid::Uuid::from_u128(3),
16039 seller_character_id: uuid::Uuid::from_u128(2),
16040 seller_label: "Ada".into(),
16041 hall_building_id: "town_market".into(),
16042 hall_label: "Town Market".into(),
16043 template_id: "short_sword".into(),
16044 display_name: "Short Sword".into(),
16045 category: "weapon".into(),
16046 quantity: 1,
16047 unit_price_copper: 100,
16048 line_total_copper: 100,
16049 npc_price: false,
16050 npc_dump_unit_copper: None,
16051 mine: false,
16052 },
16053 ],
16054 tax_bps: 0,
16055 tax_flat_copper: 0,
16056 list_vaults: vec![],
16057 });
16058 assert_eq!(state.market_filtered_listing_indices().len(), 2);
16059 state.market_category_filter = Some("Weapons");
16060 let weapons = state.market_filtered_listing_indices();
16061 assert_eq!(weapons.len(), 1);
16062 assert_eq!(
16063 state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
16064 "Short Sword"
16065 );
16066 state.market_category_filter = None;
16067 state.market_filter = "oak".into();
16068 let oak = state.market_filtered_listing_indices();
16069 assert_eq!(oak.len(), 1);
16070 assert_eq!(
16071 state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
16072 "Oak Log"
16073 );
16074 }
16075
16076 #[test]
16077 fn market_list_source_includes_person_and_vaults() {
16078 let mut state = sample_state();
16079 let item_id = uuid::Uuid::from_u128(1);
16080 state.inventory_stacks = vec![flatland_protocol::ItemStack {
16081 template_id: "oak_log".into(),
16082 quantity: 2,
16083 item_instance_id: Some(item_id),
16084 display_name: Some("Oak Log".into()),
16085 ..Default::default()
16086 }];
16087 state.market_panel = Some(flatland_protocol::MarketPanel {
16088 npc_id: "mira_market".into(),
16089 npc_label: "Mira".into(),
16090 building_id: "town_market".into(),
16091 building_label: "Town Market".into(),
16092 used_volume: 0.0,
16093 max_volume: 100.0,
16094 listings: vec![],
16095 tax_bps: 0,
16096 tax_flat_copper: 0,
16097 list_vaults: vec![flatland_protocol::MarketListVault {
16098 building_id: "town_storage".into(),
16099 building_label: "Town Storage".into(),
16100 contents: vec![flatland_protocol::ItemStack {
16101 template_id: "lumber".into(),
16102 quantity: 1,
16103 item_instance_id: Some(uuid::Uuid::from_u128(2)),
16104 display_name: Some("Lumber".into()),
16105 ..Default::default()
16106 }],
16107 }],
16108 });
16109 let sources = state.market_list_source_options();
16110 assert_eq!(sources.len(), 2);
16111 assert!(matches!(sources[0].0, MarketListSourceKind::Person));
16112 assert!(matches!(
16113 sources[1].0,
16114 MarketListSourceKind::TownStorage { .. }
16115 ));
16116 assert!(sources[1].1.contains("Town Storage"));
16117 }
16118
16119 #[test]
16120 fn npc_market_dump_estimate_from_town_storage_vault() {
16121 let mut state = sample_state();
16122 state.market_panel = Some(flatland_protocol::MarketPanel {
16123 npc_id: "mira_market".into(),
16124 npc_label: "Mira".into(),
16125 building_id: "town_market".into(),
16126 building_label: "Town Market".into(),
16127 used_volume: 0.0,
16128 max_volume: 100.0,
16129 listings: vec![],
16130 tax_bps: 0,
16131 tax_flat_copper: 0,
16132 list_vaults: vec![flatland_protocol::MarketListVault {
16133 building_id: "town_storage".into(),
16134 building_label: "Town Storage".into(),
16135 contents: vec![flatland_protocol::ItemStack {
16136 template_id: "lumber".into(),
16137 quantity: 3,
16138 item_instance_id: Some(uuid::Uuid::from_u128(2)),
16139 display_name: Some("Lumber".into()),
16140 base_value_copper: Some(20),
16141 ..Default::default()
16142 }],
16143 }],
16144 });
16145 assert_eq!(
16146 state.npc_market_dump_unit_estimate("lumber"),
16147 Some(9),
16148 "vault stack base_value should enable NPC price estimate"
16149 );
16150 }
16151
16152 #[test]
16153 fn probe_use_world_npc_beats_nearby_loot() {
16154 let mut state = sample_state();
16155 state.npcs.push(flatland_protocol::NpcView {
16156 id: "ada".into(),
16157 label: "Ada".into(),
16158 role: "broker".into(),
16159 x: 129.0,
16160 y: 128.0,
16161 building_id: None,
16162 entity_id: None,
16163 life_state: None,
16164 hp_pct: None,
16165 can_trade: true,
16166 buy_templates: vec!["lumber".into()],
16167 tile_id: None,
16168 behavior_state: None,
16169 presentation_state: None,
16170 sprite_mode: None,
16171 paperdoll_ref: None,
16172 draw_scale: 1.0,
16173 yaw: None,
16174 perception_fov_deg: None,
16175 perception_sight_m: None,
16176 perception_hear_m: None,
16177 });
16178 state.ground_drops.push(flatland_protocol::GroundDropView {
16179 id: "d1".into(),
16180 template_id: "lumber".into(),
16181 quantity: 1,
16182 x: 128.5,
16183 y: 128.0,
16184 z: 0.0,
16185 tile_id: None,
16186 display_name: None,
16187 yaw: 0.0,
16188 pitch: 0.0,
16189 roll: 0.0,
16190 draw_scale: 1.0,
16191 });
16192 let probe = state.probe_use_world();
16193 let primary = probe.primary.expect("primary");
16194 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
16195 assert_eq!(primary.id, "ada");
16196 }
16197
16198 #[test]
16199 fn probe_use_world_harvest_when_in_range() {
16200 let state = sample_state(); let probe = state.probe_use_world();
16202 assert!(
16203 probe.primary.is_none(),
16204 "oak is 2m away, out of harvest range"
16205 );
16206 assert!(probe
16207 .candidates
16208 .iter()
16209 .any(|c| c.kind == crate::UseWorldKind::Harvest));
16210
16211 let mut state = sample_state();
16212 state.resource_nodes[0].x = 129.0;
16213 let probe = state.probe_use_world();
16214 let primary = probe.primary.expect("primary");
16215 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
16216 }
16217
16218 #[test]
16219 fn probe_use_world_door_uses_building_label() {
16220 let mut state = sample_state();
16221 state.doors[0].x = 129.0;
16222 state.doors[0].y = 128.0;
16223 let probe = state.probe_use_world();
16224 let primary = probe.primary.expect("primary");
16225 assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
16226 assert_eq!(primary.label, "Broker");
16227 assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
16228 }
16229
16230 #[test]
16231 fn empty_entity_tick_preserves_welcome_snapshot() {
16232 let mut state = sample_state();
16233 state.inventory.insert("carrot".into(), 3);
16234 let delta = TickDelta {
16235 tick: 1,
16236 entities: vec![],
16237 resource_nodes: vec![],
16238 ground_drops: vec![],
16239 placed_containers: vec![],
16240 buildings: vec![],
16241 doors: vec![],
16242 interior_map: None,
16243 npcs: vec![],
16244 inventory: vec![],
16245 blueprints: vec![],
16246 building_materials: vec![],
16247 world_clock: flatland_protocol::WorldClock::default(),
16248 combat: None,
16249 quest_log: vec![],
16250 hired_workers: Vec::new(),
16251 interactables: vec![],
16252 ledger: None,
16253 career: None,
16254 combat_fx: Vec::new(),
16255 ground_hazards: Vec::new(),
16256 property_plots: Vec::new(),
16257 terrain_overlays: Vec::new(),
16258 };
16259
16260 state.apply_tick_fields(&delta, 1);
16261
16262 assert_eq!(state.entities.len(), 1);
16263 assert!(state.player.is_some());
16264 assert_eq!(state.inventory.get("carrot"), Some(&3));
16265 assert_eq!(state.resource_nodes.len(), 1);
16266 }
16267
16268 #[test]
16269 fn tick_preserves_world_layers_when_delta_omits_them() {
16270 let mut state = sample_state();
16271 let delta = TickDelta {
16272 tick: 1,
16273 entities: state.entities.clone(),
16274 resource_nodes: vec![],
16275 ground_drops: vec![],
16276 placed_containers: vec![],
16277 buildings: vec![],
16278 doors: vec![],
16279 interior_map: None,
16280 npcs: vec![],
16281 inventory: vec![],
16282 blueprints: vec![],
16283 building_materials: vec![],
16284 world_clock: flatland_protocol::WorldClock::default(),
16285 combat: None,
16286 quest_log: vec![],
16287 hired_workers: Vec::new(),
16288 interactables: vec![],
16289 ledger: None,
16290 career: None,
16291 combat_fx: Vec::new(),
16292 ground_hazards: Vec::new(),
16293 property_plots: Vec::new(),
16294 terrain_overlays: Vec::new(),
16295 };
16296
16297 state.apply_tick_fields(&delta, 1);
16298
16299 assert_eq!(state.resource_nodes.len(), 1);
16300 assert_eq!(state.buildings.len(), 1);
16301 assert_eq!(state.doors.len(), 1);
16302 }
16303
16304 #[test]
16305 fn tick_updates_resource_nodes_when_server_sends_them() {
16306 let mut state = sample_state();
16307 let delta = TickDelta {
16308 tick: 1,
16309 entities: state.entities.clone(),
16310 resource_nodes: vec![ResourceNodeView {
16311 id: "oak-1".into(),
16312 label: "Oak".into(),
16313 x: 130.0,
16314 y: 128.0,
16315 z: 0.0,
16316 item_template: "oak_log".into(),
16317 state: ResourceNodeState::Cooldown,
16318 blocking: true,
16319 blocking_radius_m: 0.8,
16320 harvest_off: false,
16321 tile_id: None,
16322 yaw: 0.0,
16323 pitch: 0.0,
16324 roll: 0.0,
16325 draw_scale: 1.0,
16326 sprite_mode: None,
16327 growth_progress: None,
16328 presentation_state: None,
16329 channel_start_tick: None,
16330 channel_end_tick: None,
16331 harvest_drop_templates: vec![],
16332 }],
16333 buildings: vec![],
16334 doors: vec![],
16335 interior_map: None,
16336 npcs: vec![],
16337 inventory: vec![],
16338 blueprints: vec![],
16339 building_materials: vec![],
16340 world_clock: flatland_protocol::WorldClock::default(),
16341 ground_drops: vec![],
16342 placed_containers: vec![],
16343 combat: None,
16344 quest_log: vec![],
16345 hired_workers: Vec::new(),
16346 interactables: vec![],
16347 ledger: None,
16348 career: None,
16349 combat_fx: Vec::new(),
16350 ground_hazards: Vec::new(),
16351 property_plots: Vec::new(),
16352 terrain_overlays: Vec::new(),
16353 };
16354
16355 state.apply_tick_fields(&delta, 1);
16356
16357 assert!(matches!(
16358 state.resource_nodes[0].state,
16359 ResourceNodeState::Cooldown
16360 ));
16361 }
16362
16363 #[test]
16364 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
16365 let mut state = GameState {
16366 session_id: 1,
16367 entity_id: 1,
16368 character_id: None,
16369 tick: 0,
16370 chunk_rev: 0,
16371 content_rev: 0,
16372 publish_rev: 0,
16373 entities: vec![EntityState {
16374 id: 1,
16375 label: "You".into(),
16376 transform: Transform {
16377 position: WorldCoord::surface(4.5, 2.0),
16378 yaw: 0.0,
16379 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
16380 },
16381 vitals: None,
16382 attributes: None,
16383 skills: None,
16384 inside_building: Some("broker_hut".into()),
16385 tile_id: None,
16386 paperdoll_ref: None,
16387 draw_scale: 1.0,
16388 presentation_state: None,
16389 sprite_mode: None,
16390 progression_xp: None,
16391 combat_cues: vec![],
16392 statuses: vec![],
16393 }],
16394 player: None,
16395 resource_nodes: vec![],
16396 ground_drops: vec![],
16397 placed_containers: vec![],
16398 buildings: vec![BuildingView {
16399 id: "broker_hut".into(),
16400 label: "Broker".into(),
16401 x: 158.0,
16402 y: 124.0,
16403 width_m: 8.0,
16404 depth_m: 6.0,
16405 interior_blueprint: Some("broker_hut".into()),
16406 tags: vec![],
16407 market_boundary_zone_ids: vec![],
16408 market_max_volume: None,
16409 wall_set: None,
16410 roof_set: None,
16411 }],
16412 doors: vec![flatland_protocol::DoorView {
16413 id: "broker_hut_exit".into(),
16414 building_id: "broker_hut".into(),
16415 x: 4.3,
16416 y: 0.9,
16417 open: true,
16418 portal: Some("front".into()),
16419 locked: false,
16420 accessible: true,
16421 lock_id: None,
16422 }],
16423 interior_map: None,
16424 npcs: vec![flatland_protocol::NpcView {
16425 id: "ada_broker".into(),
16426 label: "Ada".into(),
16427 x: 4.5,
16428 y: 2.0,
16429 building_id: Some("broker_hut".into()),
16430 role: "broker".into(),
16431 entity_id: None,
16432 life_state: None,
16433 hp_pct: None,
16434 can_trade: true,
16435 buy_templates: vec!["lumber".into()],
16436 tile_id: None,
16437 behavior_state: None,
16438 presentation_state: None,
16439 sprite_mode: None,
16440 paperdoll_ref: None,
16441 draw_scale: 1.0,
16442 yaw: None,
16443 perception_fov_deg: None,
16444 perception_sight_m: None,
16445 perception_hear_m: None,
16446 }],
16447 blueprints: vec![],
16448 building_materials: vec![],
16449 world_x0: 0.0,
16450 world_y0: 0.0,
16451 world_width_m: 256.0,
16452 world_height_m: 256.0,
16453 terrain_zones: Vec::new(),
16454 z_platforms: Vec::new(),
16455 z_transitions: Vec::new(),
16456 z_bands_outdoor_backup: None,
16457 world_clock: flatland_protocol::WorldClock::default(),
16458 inventory: std::collections::HashMap::new(),
16459 inventory_hints: std::collections::HashMap::new(),
16460 logs: VecDeque::new(),
16461 intents_sent: 0,
16462 ticks_received: 0,
16463 connected: true,
16464 disconnect_reason: None,
16465 show_stats: false,
16466 hud_log_hidden: false,
16467 show_equip_menu: false,
16468 equip_menu_index: 0,
16469 show_craft_menu: false,
16470 show_plot_build_menu: false,
16471 plot_build_focus_wall: true,
16472 plot_build_wall_index: 0,
16473 plot_build_roof_index: 0,
16474 craft_menu_index: 0,
16475 craft_batch_quantity: 1,
16476 show_shop_menu: false,
16477 shop_catalog: None,
16478 bank_panel: None,
16479 bank_menu_index: 0,
16480 bank_ui_mode: BankUiMode::Menu,
16481 storage_panel: None,
16482 market_panel: None,
16483 market_menu_index: 0,
16484 market_filter: String::new(),
16485 market_filter_focused: false,
16486 market_category_filter: None,
16487 market_buy_confirm: None,
16488 market_ui_mode: MarketUiMode::Browse,
16489 storage_menu_index: 0,
16490 storage_ui_mode: StorageUiMode::Menu,
16491 shop_tab: ShopTab::default(),
16492 shop_menu_index: 0,
16493 shop_quantity: 1,
16494 shop_trade_log: VecDeque::new(),
16495 show_npc_verb_menu: false,
16496 npc_verb_target: None,
16497 npc_verb_index: 0,
16498 player_verbs: crate::social::PlayerVerbState::default(),
16499 social_chat: crate::social::SocialChatState::default(),
16500 trade_ui: crate::social::TradeUiState::default(),
16501 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
16502 show_npc_chat: false,
16503 npc_chat: None,
16504 show_inventory_menu: false,
16505 inventory_menu_index: 0,
16506 inventory_tab: InventoryTab::OnPerson,
16507 inventory_filter: String::new(),
16508 inventory_filter_focused: false,
16509 show_move_picker: false,
16510 show_rename_prompt: false,
16511 rename_plot_id: None,
16512 highlighted_plot_id: None,
16513 show_worker_rename: false,
16514 rename_buffer: String::new(),
16515 move_picker_index: 0,
16516 move_picker: None,
16517 show_grant_picker: false,
16518 grant_picker_index: 0,
16519 grant_picker: None,
16520 show_destroy_picker: false,
16521 destroy_confirm_pending: false,
16522 destroy_picker: None,
16523 combat_target: None,
16524 combat_target_label: None,
16525 ground_target: None,
16526 combat_fx: Vec::new(),
16527 ground_hazards: Vec::new(),
16528 property_zones: Vec::new(),
16529 tax_zones: Vec::new(),
16530 growth_zones: Vec::new(),
16531 biome_zones: Vec::new(),
16532 terrain_kind_nav: Vec::new(),
16533 property_plots: Vec::new(),
16534 property_plot_settings: None,
16535 claim_mode: None,
16536 relocate_mode: None,
16537 sell_plot_confirm: None,
16538 sell_plot_armed_at: None,
16539 show_plant_menu: false,
16540 plant_menu_index: 0,
16541 show_farm_access: false,
16542 farm_access_name_draft: String::new(),
16543 farm_access_discount_bps: 0,
16544 farm_access_index: 0,
16545 plant_quantity: 1,
16546 in_combat: false,
16547 auto_attack: true,
16548 combat_has_los: false,
16549 attack_cd_ticks: 0,
16550 gcd_ticks: 0,
16551 weapon_ability_id: "unarmed".into(),
16552 mainhand_template_id: None,
16553 mainhand_label: None,
16554 mainhand_instance_id: None,
16555 offhand_template_id: None,
16556 offhand_label: None,
16557 offhand_instance_id: None,
16558 mainhand_hand_slots: 1,
16559 defense: None,
16560 worn: BTreeMap::new(),
16561 carry_mass: 0.0,
16562 carry_mass_max: 0.0,
16563 encumbrance: flatland_protocol::EncumbranceState::Light,
16564 inventory_stacks: Vec::new(),
16565 keychain_stacks: Vec::new(),
16566 whisper_pouch_stacks: Vec::new(),
16567 combat_target_detail: None,
16568 statuses: Vec::new(),
16569 cast_progress: None,
16570 timed_channel: None,
16571 plot_build_offer: None,
16572 ability_cooldowns: Vec::new(),
16573 blocking_active: false,
16574 max_target_slots: 1,
16575 combat_slots: Vec::new(),
16576 rotation_presets: Vec::new(),
16577 known_abilities: Vec::new(),
16578 ability_meta: std::collections::HashMap::new(),
16579 ability_mastery: std::collections::HashMap::new(),
16580 hotbar: vec![None; 9],
16581 max_abilities_per_rotation: 0,
16582 show_loadout_menu: false,
16583 show_keychain_menu: false,
16584 keychain_menu_index: 0,
16585 show_rotation_editor: false,
16586 loadout_menu_index: 0,
16587 loadout_hotbar_slot: 1,
16588 loadout_ability_index: 0,
16589 loadout_focus_presets: false,
16590 rotation_editor: RotationEditorState::default(),
16591 harvest_in_progress: false,
16592 harvest_started_at: None,
16593 pending_craft_ack: None,
16594 pending_worker_job_ack: None,
16595 attending_worker_instance_id: None,
16596 quest_log: Vec::new(),
16597 interactables: Vec::new(),
16598 ledger: None,
16599 career: None,
16600 character_sheet_tab: CharacterSheetTab::Character,
16601 ledger_period: LedgerPeriod::Day,
16602 show_quest_offer: false,
16603 pending_quest_offer: None,
16604 show_quest_menu: false,
16605 quest_menu_index: 0,
16606 quest_withdraw_confirm: false,
16607 hired_workers: Vec::new(),
16608 show_workers_menu: false,
16609 workers_menu_index: 0,
16610 worker_dismiss_confirmation: None,
16611 workers_menu_compact: false,
16612 worker_step_display: BTreeMap::new(),
16613 worker_error_display: BTreeMap::new(),
16614 worker_health_ring_until: BTreeMap::new(),
16615 pending_worker_hire_since: None,
16616 show_worker_give_picker: false,
16617 worker_give_picker_index: 0,
16618 worker_give_picker: None,
16619 show_worker_give_target_picker: false,
16620 worker_give_target_picker_index: 0,
16621 worker_give_target_picker: None,
16622 show_worker_take_picker: false,
16623 worker_take_picker_index: 0,
16624 worker_take_picker: None,
16625 show_worker_teach_picker: false,
16626 worker_teach_picker_index: 0,
16627 worker_teach_picker: None,
16628 worker_route_editor: None,
16629 progression_curve: None,
16630 };
16631 state.player = state.entities.first().cloned();
16632 assert_eq!(
16633 state.nearest_interact_target().as_deref(),
16634 Some("ada_broker")
16635 );
16636 }
16637
16638 #[test]
16639 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
16640 let mut state = sample_state();
16641 state.placed_containers = vec![
16644 flatland_protocol::PlacedContainerView {
16645 id: "near".into(),
16646 template_id: "wooden_chest_small".into(),
16647 display_name: "Wooden Chest".into(),
16648 x: 130.0,
16649 y: 128.0,
16650 z: 0.0,
16651 locked: true,
16652 accessible: true,
16653 owner_character_id: None,
16654 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
16655 lock_id: None,
16656 capacity_volume: None,
16657 item_instance_id: Some(uuid::Uuid::from_u128(1)),
16658 tile_id: None,
16659 worker_lodging_capacity: None,
16660 blocking: false,
16661 blocking_radius_m: 0.0,
16662 building_id: None,
16663 },
16664 flatland_protocol::PlacedContainerView {
16665 id: "far".into(),
16666 template_id: "wooden_chest_small".into(),
16667 display_name: "Distant Chest".into(),
16668 x: 128.0 + CONTAINER_RANGE_M + 5.0,
16669 y: 128.0,
16670 z: 0.0,
16671 locked: false,
16672 accessible: true,
16673 owner_character_id: None,
16674 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
16675 lock_id: None,
16676 capacity_volume: None,
16677 item_instance_id: Some(uuid::Uuid::from_u128(2)),
16678 tile_id: None,
16679 worker_lodging_capacity: None,
16680 blocking: false,
16681 blocking_radius_m: 0.0,
16682 building_id: None,
16683 },
16684 ];
16685
16686 let nearby = state.nearby_containers();
16687 assert_eq!(
16688 nearby.len(),
16689 1,
16690 "far chest must not appear once out of range"
16691 );
16692 assert_eq!(nearby[0].view.id, "near");
16693 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
16694 assert!(nearby[0].rows[0].is_chest_shell);
16695
16696 state.placed_containers[0].accessible = false;
16699 let nearby = state.nearby_containers();
16700 assert_eq!(nearby.len(), 1);
16701 assert_eq!(nearby[0].rows.len(), 1);
16702 assert!(nearby[0].rows[0].is_chest_shell);
16703 }
16704
16705 #[test]
16706 fn chest_pickup_destinations_offer_person_and_worn_bag() {
16707 let mut state = sample_state();
16708 let back_id = uuid::Uuid::from_u128(42);
16709 state.worn.insert(
16710 BodySlot::Back,
16711 flatland_protocol::ItemStack {
16712 template_id: "travel_backpack".into(),
16713 quantity: 1,
16714 item_instance_id: Some(back_id),
16715 props: Default::default(),
16716 status_bindings: Vec::new(),
16717 contents: Vec::new(),
16718 display_name: Some("Travel Backpack".into()),
16719 category: Some("container".into()),
16720 base_mass: Some(2.5),
16721 base_volume: Some(12.0),
16722 capacity_volume: Some(80.0),
16723 stackable: Some(false),
16724 world_placeable: Some(false),
16725 worker_lodging_capacity: None,
16726 equip_slot: None,
16727 armor_physical: None,
16728 resists: vec![],
16729 hand_slots: None,
16730 listable: None,
16731 ..Default::default()
16732 },
16733 );
16734 let opts = state.chest_pickup_destinations("chest-1");
16735 assert!(matches!(
16736 opts.first().map(|o| &o.kind),
16737 Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
16738 ));
16739 assert!(opts.iter().any(|o| matches!(
16740 &o.kind,
16741 MoveOptionKind::PickupPlaced {
16742 nest_parent_instance_id: None,
16743 ..
16744 }
16745 )));
16746 assert!(opts.iter().any(|o| matches!(
16747 &o.kind,
16748 MoveOptionKind::PickupPlaced {
16749 nest_parent_instance_id: Some(id),
16750 ..
16751 } if *id == back_id
16752 )));
16753 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
16754 }
16755
16756 #[test]
16757 fn placed_container_public_label_hides_owner_custom_name() {
16758 let owner = uuid::Uuid::from_u128(99);
16759 let mut state = sample_state();
16760 state.character_id = Some(uuid::Uuid::from_u128(1));
16761 state.inventory_hints.insert(
16762 "wooden_chest_medium".into(),
16763 InventoryHint {
16764 display_name: "Medium Wooden Chest".into(),
16765 category: "container".into(),
16766 base_mass: None,
16767 base_volume: None,
16768 capacity_volume: None,
16769 stackable: false,
16770 listable: true,
16771 base_value_copper: None,
16772 },
16773 );
16774 let chest = flatland_protocol::PlacedContainerView {
16775 id: "c1".into(),
16776 template_id: "wooden_chest_medium".into(),
16777 display_name: "Barry's Loot #a3f2".into(),
16778 x: 128.0,
16779 y: 128.0,
16780 z: 0.0,
16781 locked: false,
16782 accessible: true,
16783 owner_character_id: Some(owner),
16784 contents: vec![],
16785 lock_id: None,
16786 capacity_volume: None,
16787 item_instance_id: None,
16788 tile_id: None,
16789 worker_lodging_capacity: None,
16790 blocking: false,
16791 blocking_radius_m: 0.0,
16792 building_id: None,
16793 };
16794 assert_eq!(
16795 state.placed_container_public_label(&chest),
16796 "Medium Wooden Chest"
16797 );
16798 state.character_id = Some(owner);
16799 assert_eq!(
16800 state.placed_container_public_label(&chest),
16801 "Barry's Loot #a3f2"
16802 );
16803 }
16804
16805 #[test]
16806 fn location_context_shows_crop_growth_percent_not_depleted() {
16807 let mut state = sample_state();
16808 state.player = state.entities.first().cloned();
16809 state.resource_nodes[0].label = "Carrot (growing)".into();
16810 state.resource_nodes[0].x = 128.2;
16811 state.resource_nodes[0].y = 128.0;
16812 state.resource_nodes[0].state = ResourceNodeState::Cooldown;
16813 state.resource_nodes[0].growth_progress = Some(0.47);
16814 let lines = state.location_context_lines();
16815 let line = lines
16816 .iter()
16817 .find(|l| l.text.contains("Carrot"))
16818 .map(|l| l.text.as_str())
16819 .unwrap_or("");
16820 assert!(
16821 line.contains("(growing, 47%)"),
16822 "expected growth percent, got: {line}"
16823 );
16824 assert!(
16825 !line.contains("depleted"),
16826 "growing crop should not show depleted: {line}"
16827 );
16828 }
16829
16830 #[test]
16831 fn resource_node_near_action_suffix_prefers_growth() {
16832 let node = ResourceNodeView {
16833 id: "crop".into(),
16834 label: "Wheat".into(),
16835 x: 0.0,
16836 y: 0.0,
16837 z: 0.0,
16838 item_template: "wheat".into(),
16839 state: ResourceNodeState::Cooldown,
16840 blocking: false,
16841 blocking_radius_m: 0.0,
16842 harvest_off: false,
16843 tile_id: None,
16844 yaw: 0.0,
16845 pitch: 0.0,
16846 roll: 0.0,
16847 draw_scale: 1.0,
16848 sprite_mode: None,
16849 growth_progress: Some(0.12),
16850 presentation_state: None,
16851 channel_start_tick: None,
16852 channel_end_tick: None,
16853 harvest_drop_templates: vec![],
16854 };
16855 assert_eq!(resource_node_near_action_suffix(&node), " (growing, 12%)");
16856 }
16857
16858 #[test]
16859 fn location_context_lists_nearby_resource_node() {
16860 let mut state = sample_state();
16861 state.player = state.entities.first().cloned();
16862 state.resource_nodes[0].x = 128.2;
16863 state.resource_nodes[0].y = 128.0;
16864 let lines = state.location_context_lines();
16865 assert!(
16866 lines
16867 .iter()
16868 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
16869 "expected resource node in context: {:?}",
16870 lines
16871 );
16872 }
16873
16874 #[test]
16875 fn quest_board_usable_within_board_radius() {
16876 let mut state = sample_state();
16877 state.player = state.entities.first().cloned();
16878 state.interactables = vec![flatland_protocol::InteractableView {
16879 id: "board-1".into(),
16880 kind: "quest_board".into(),
16881 label: "Town Quest Board".into(),
16882 x: 130.5,
16883 y: 128.0,
16884 z: 0.0,
16885 board_id: Some("starter_town_board".into()),
16886 }];
16887 assert_eq!(
16889 state.nearest_interact_target().as_deref(),
16890 Some("board-1"),
16891 "quest board should be selectable at ~2.5m"
16892 );
16893 let lines = state.location_context_lines();
16894 assert!(
16895 lines
16896 .iter()
16897 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
16898 "HUD should advertise f when board is in range: {:?}",
16899 lines
16900 );
16901 }
16902
16903 #[test]
16904 fn inventory_selectable_rows_excludes_equipped_shells_but_keeps_bag_contents() {
16905 let mut state = sample_state();
16906 state.worn.insert(
16907 BodySlot::Back,
16908 flatland_protocol::ItemStack {
16909 template_id: "travel_backpack".into(),
16910 quantity: 1,
16911 item_instance_id: Some(uuid::Uuid::from_u128(3)),
16912 props: Default::default(),
16913 status_bindings: Vec::new(),
16914 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
16915 display_name: None,
16916 category: None,
16917 base_mass: None,
16918 base_volume: None,
16919 capacity_volume: None,
16920 stackable: None,
16921 world_placeable: None,
16922 worker_lodging_capacity: None,
16923 equip_slot: None,
16924 armor_physical: None,
16925 resists: vec![],
16926 hand_slots: None,
16927 listable: None,
16928 ..Default::default()
16929 },
16930 );
16931 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
16932 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16933 id: "chest-1".into(),
16934 template_id: "wooden_chest_small".into(),
16935 display_name: "Wooden Chest".into(),
16936 x: 129.0,
16937 y: 128.0,
16938 z: 0.0,
16939 locked: false,
16940 accessible: true,
16941 owner_character_id: None,
16942 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
16943 lock_id: None,
16944 capacity_volume: None,
16945 item_instance_id: Some(uuid::Uuid::from_u128(4)),
16946 tile_id: None,
16947 worker_lodging_capacity: None,
16948 blocking: false,
16949 blocking_radius_m: 0.0,
16950 building_id: None,
16951 }];
16952
16953 state.inventory_tab = InventoryTab::OnPerson;
16954 let rows = state.inventory_selectable_rows();
16955 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
16956 assert_eq!(
16957 sections,
16958 vec![
16959 InventorySection::Person, InventorySection::Person, ]
16962 );
16963 assert_eq!(rows[0].stack.template_id, "iron_ore");
16964 assert_eq!(rows[0].depth, 0);
16965 assert!(!rows[0].is_equip_shell);
16966 assert_eq!(rows[1].stack.template_id, "lumber");
16967
16968 let lines = state.inventory_browser_lines();
16969 assert!(lines.iter().any(|l| matches!(
16970 l,
16971 InventoryBrowserLine::Section(s) if s.contains("carried bags")
16972 )));
16973 assert!(lines.iter().any(|l| matches!(
16974 l,
16975 InventoryBrowserLine::Item { text, .. } if text.contains("iron_ore")
16976 )));
16977 assert!(!lines.iter().any(|l| matches!(
16978 l,
16979 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
16980 )));
16981 assert!(!lines.iter().any(|l| matches!(
16982 l,
16983 InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
16984 )));
16985
16986 state.inventory_tab = InventoryTab::Nearby;
16987 let nearby_rows = state.inventory_selectable_rows();
16988 assert_eq!(nearby_rows.len(), 2);
16989 assert!(nearby_rows[0].is_chest_shell);
16990 assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
16991 let nearby_lines = state.inventory_browser_lines();
16992 assert!(nearby_lines.iter().any(|l| matches!(
16993 l,
16994 InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
16995 )));
16996 }
16997
16998 #[test]
16999 fn give_worker_notice_does_not_put_item_back_in_bag() {
17000 let mut state = sample_state();
17001 let id = uuid::Uuid::from_u128(42);
17002 let mut saw = flatland_protocol::ItemStack::simple("handsaw", 1);
17003 saw.item_instance_id = Some(id);
17004 saw.display_name = Some("Handsaw".into());
17005 state.sync_inventory_from_stacks(&[saw]);
17006 assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 1);
17007
17008 state.remove_carried_instance(id, None);
17009 assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 0);
17010 assert!(state.inventory_stacks.is_empty());
17011
17012 state.apply_interaction_notice(&flatland_protocol::InteractionNotice {
17013 target_id: "worker-1".into(),
17014 message: "Gave 1x Handsaw to Laborer".into(),
17015 coins_delta: 0,
17016 inventory_delta: vec![flatland_protocol::ItemStack::simple("handsaw", 1)],
17017 });
17018 assert_eq!(
17019 state.inventory.get("handsaw").copied().unwrap_or(0),
17020 0,
17021 "Gave notice must not restore the handed stack"
17022 );
17023 }
17024
17025 #[test]
17026 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
17027 let mut state = sample_state();
17028 let back_id = uuid::Uuid::from_u128(5);
17029 state.worn.insert(
17030 BodySlot::Back,
17031 flatland_protocol::ItemStack {
17032 template_id: "travel_backpack".into(),
17033 quantity: 1,
17034 item_instance_id: Some(back_id),
17035 props: Default::default(),
17036 status_bindings: Vec::new(),
17037 contents: Vec::new(),
17038 display_name: None,
17039 category: Some("container".into()),
17040 base_mass: None,
17041 base_volume: None,
17042 capacity_volume: Some(80.0),
17043 stackable: None,
17044 world_placeable: None,
17045 worker_lodging_capacity: None,
17046 equip_slot: None,
17047 armor_physical: None,
17048 resists: vec![],
17049 hand_slots: None,
17050 listable: None,
17051 ..Default::default()
17052 },
17053 );
17054 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17055 id: "chest-1".into(),
17056 template_id: "wooden_chest_small".into(),
17057 display_name: "Wooden Chest".into(),
17058 x: 129.0,
17059 y: 128.0,
17060 z: 0.0,
17061 locked: false,
17062 accessible: true,
17063 owner_character_id: None,
17064 contents: Vec::new(),
17065 lock_id: None,
17066 capacity_volume: None,
17067 item_instance_id: Some(uuid::Uuid::from_u128(6)),
17068 tile_id: None,
17069 worker_lodging_capacity: None,
17070 blocking: false,
17071 blocking_radius_m: 0.0,
17072 building_id: None,
17073 }];
17074
17075 let opts = state.move_destinations_for(
17078 &flatland_protocol::InventoryLocation::Root,
17079 None,
17080 None,
17081 "lumber",
17082 );
17083 assert!(!opts.iter().any(|o| matches!(
17084 &o.kind,
17085 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
17086 )));
17087 assert!(opts.iter().any(|o| matches!(
17088 &o.kind,
17089 MoveOptionKind::Move { location, parent_instance_id, .. }
17090 if *location == flatland_protocol::InventoryLocation::Worn {
17091 slot: BodySlot::Back,
17092 } && *parent_instance_id == Some(back_id)
17093 )));
17094 assert!(opts.iter().any(|o| matches!(
17095 &o.kind,
17096 MoveOptionKind::Move { location, .. }
17097 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
17098 )));
17099 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
17100 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
17101
17102 let from_backpack = flatland_protocol::InventoryLocation::Worn {
17106 slot: BodySlot::Back,
17107 };
17108 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
17109 assert!(!opts.iter().any(|o| matches!(
17110 &o.kind,
17111 MoveOptionKind::Move { location, parent_instance_id, .. }
17112 if *location == from_backpack && *parent_instance_id == Some(back_id)
17113 )));
17114 assert!(opts.iter().any(|o| matches!(
17115 &o.kind,
17116 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
17117 )));
17118 }
17119
17120 #[test]
17121 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
17122 let mut state = sample_state();
17123 state.worn.insert(
17126 BodySlot::Waist,
17127 flatland_protocol::ItemStack {
17128 template_id: "simple_belt".into(),
17129 quantity: 1,
17130 item_instance_id: Some(uuid::Uuid::from_u128(10)),
17131 props: Default::default(),
17132 status_bindings: Vec::new(),
17133 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
17134 display_name: None,
17135 category: Some("container".into()),
17136 base_mass: None,
17137 base_volume: None,
17138 capacity_volume: None,
17139 stackable: None,
17140 world_placeable: None,
17141 worker_lodging_capacity: None,
17142 equip_slot: None,
17143 armor_physical: None,
17144 resists: vec![],
17145 hand_slots: None,
17146 listable: None,
17147 ..Default::default()
17148 },
17149 );
17150 state.worn.insert(
17151 BodySlot::Head,
17152 flatland_protocol::ItemStack {
17153 template_id: "cloth_cap".into(),
17154 quantity: 1,
17155 item_instance_id: Some(uuid::Uuid::from_u128(11)),
17156 props: Default::default(),
17157 status_bindings: Vec::new(),
17158 contents: Vec::new(),
17159 display_name: None,
17160 category: Some("armor".into()),
17161 base_mass: None,
17162 base_volume: None,
17163 capacity_volume: None,
17164 stackable: None,
17165 world_placeable: None,
17166 worker_lodging_capacity: None,
17167 equip_slot: None,
17168 armor_physical: None,
17169 resists: vec![],
17170 hand_slots: None,
17171 listable: None,
17172 ..Default::default()
17173 },
17174 );
17175 state.worn.insert(
17176 BodySlot::Back,
17177 flatland_protocol::ItemStack {
17178 template_id: "travel_backpack".into(),
17179 quantity: 1,
17180 item_instance_id: Some(uuid::Uuid::from_u128(12)),
17181 props: Default::default(),
17182 status_bindings: Vec::new(),
17183 contents: Vec::new(),
17184 display_name: None,
17185 category: Some("container".into()),
17186 base_mass: None,
17187 base_volume: None,
17188 capacity_volume: None,
17189 stackable: None,
17190 world_placeable: None,
17191 worker_lodging_capacity: None,
17192 equip_slot: None,
17193 armor_physical: None,
17194 resists: vec![],
17195 hand_slots: None,
17196 listable: None,
17197 ..Default::default()
17198 },
17199 );
17200
17201 let rows = state.worn_rows();
17202 assert_eq!(rows.len(), 4);
17204 assert_eq!(rows[0].stack.template_id, "cloth_cap");
17205 assert!(rows[0].is_equip_shell);
17206 assert_eq!(rows[1].stack.template_id, "travel_backpack");
17207 assert!(rows[1].is_equip_shell);
17208 assert_eq!(rows[2].stack.template_id, "simple_belt");
17209 assert!(rows[2].is_equip_shell);
17210 assert_eq!(rows[3].stack.template_id, "leather_pouch");
17211 assert_eq!(rows[3].depth, 1);
17212 assert!(!rows[3].is_equip_shell);
17213 }
17214
17215 #[test]
17216 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
17217 let mut state = sample_state();
17218 state.worn.insert(
17219 BodySlot::Waist,
17220 flatland_protocol::ItemStack {
17221 template_id: "simple_belt".into(),
17222 quantity: 1,
17223 item_instance_id: Some(uuid::Uuid::from_u128(20)),
17224 props: Default::default(),
17225 status_bindings: Vec::new(),
17226 contents: Vec::new(),
17227 display_name: Some("Simple Belt".into()),
17228 category: Some("container".into()),
17229 base_mass: None,
17230 base_volume: None,
17231 capacity_volume: None,
17232 stackable: None,
17233 world_placeable: None,
17234 worker_lodging_capacity: None,
17235 equip_slot: None,
17236 armor_physical: None,
17237 resists: vec![],
17238 hand_slots: None,
17239 listable: None,
17240 ..Default::default()
17241 },
17242 );
17243 state.worn.insert(
17244 BodySlot::Head,
17245 flatland_protocol::ItemStack {
17246 template_id: "cloth_cap".into(),
17247 quantity: 1,
17248 item_instance_id: Some(uuid::Uuid::from_u128(21)),
17249 props: Default::default(),
17250 status_bindings: Vec::new(),
17251 contents: Vec::new(),
17252 display_name: Some("Cloth Cap".into()),
17253 category: Some("armor".into()),
17254 base_mass: None,
17255 base_volume: None,
17256 capacity_volume: None,
17257 stackable: None,
17258 world_placeable: None,
17259 worker_lodging_capacity: None,
17260 equip_slot: None,
17261 armor_physical: None,
17262 resists: vec![],
17263 hand_slots: None,
17264 listable: None,
17265 ..Default::default()
17266 },
17267 );
17268
17269 let opts = state.move_destinations_for(
17270 &flatland_protocol::InventoryLocation::Root,
17271 None,
17272 None,
17273 "leather_pouch",
17274 );
17275 assert!(
17276 opts.iter().any(|o| matches!(
17277 &o.kind,
17278 MoveOptionKind::Move { location, .. }
17279 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
17280 )),
17281 "belt loop must be offered when moving a pouch"
17282 );
17283 assert!(
17284 !opts.iter().any(|o| matches!(
17285 &o.kind,
17286 MoveOptionKind::Move { location, .. }
17287 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
17288 )),
17289 "armor slots can't hold other items and must not appear as move destinations"
17290 );
17291 let belt_opt = opts
17292 .iter()
17293 .find(|o| matches!(
17294 &o.kind,
17295 MoveOptionKind::Move { location, .. }
17296 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
17297 ))
17298 .unwrap();
17299 assert!(belt_opt.label.contains("belt loop"));
17300
17301 let opts = state.move_destinations_for(
17302 &flatland_protocol::InventoryLocation::Root,
17303 None,
17304 None,
17305 "lumber",
17306 );
17307 assert!(
17308 !opts.iter().any(|o| o.label.contains("belt loop")),
17309 "loose materials must not target the belt shell — only nested pouches"
17310 );
17311 }
17312
17313 #[test]
17314 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
17315 let mut state = sample_state();
17316 let belt_id = uuid::Uuid::from_u128(30);
17317 let pouch_id = uuid::Uuid::from_u128(31);
17318 state.worn.insert(
17319 BodySlot::Waist,
17320 flatland_protocol::ItemStack {
17321 template_id: "simple_belt".into(),
17322 quantity: 1,
17323 item_instance_id: Some(belt_id),
17324 props: Default::default(),
17325 status_bindings: Vec::new(),
17326 world_placeable: None,
17327 worker_lodging_capacity: None,
17328 equip_slot: None,
17329 armor_physical: None,
17330 resists: vec![],
17331 hand_slots: None,
17332 contents: vec![flatland_protocol::ItemStack {
17333 template_id: "dimensional_pouch".into(),
17334 quantity: 1,
17335 item_instance_id: Some(pouch_id),
17336 props: Default::default(),
17337 status_bindings: Vec::new(),
17338 contents: Vec::new(),
17339 display_name: Some("Dimensional Pouch".into()),
17340 category: Some("container".into()),
17341 base_mass: None,
17342 base_volume: None,
17343 capacity_volume: Some(200.0),
17344 stackable: None,
17345 world_placeable: None,
17346 worker_lodging_capacity: None,
17347 equip_slot: None,
17348 armor_physical: None,
17349 resists: vec![],
17350 hand_slots: None,
17351 listable: None,
17352 ..Default::default()
17353 }],
17354 display_name: Some("Simple Belt".into()),
17355 category: Some("container".into()),
17356 base_mass: None,
17357 base_volume: None,
17358 capacity_volume: None,
17359 stackable: None,
17360 listable: None,
17361 ..Default::default()
17362 },
17363 );
17364
17365 let opts = state.move_destinations_for(
17366 &flatland_protocol::InventoryLocation::Root,
17367 None,
17368 None,
17369 "iron_ore",
17370 );
17371 assert!(
17372 opts.iter().any(|o| matches!(
17373 &o.kind,
17374 MoveOptionKind::Move {
17375 location,
17376 parent_instance_id,
17377 ..
17378 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
17379 && *parent_instance_id == Some(pouch_id)
17380 )),
17381 "dimensional pouch clipped on belt must accept loose items"
17382 );
17383 assert!(
17384 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
17385 "destination label should name the pouch"
17386 );
17387 }
17388
17389 #[test]
17390 fn container_volume_label_on_placed_chest_shell() {
17391 let mut state = sample_state();
17392 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17393 id: "chest-1".into(),
17394 template_id: "wooden_chest_small".into(),
17395 display_name: "Camp Chest".into(),
17396 x: 129.0,
17397 y: 128.0,
17398 z: 0.0,
17399 locked: false,
17400 accessible: true,
17401 owner_character_id: None,
17402 contents: vec![flatland_protocol::ItemStack {
17403 template_id: "iron_ore".into(),
17404 quantity: 2,
17405 item_instance_id: None,
17406 props: Default::default(),
17407 status_bindings: Vec::new(),
17408 contents: Vec::new(),
17409 display_name: None,
17410 category: None,
17411 base_mass: None,
17412 base_volume: Some(2.0),
17413 capacity_volume: None,
17414 stackable: None,
17415 world_placeable: None,
17416 worker_lodging_capacity: None,
17417 equip_slot: None,
17418 armor_physical: None,
17419 resists: vec![],
17420 hand_slots: None,
17421 listable: None,
17422 ..Default::default()
17423 }],
17424 lock_id: None,
17425 capacity_volume: Some(60.0),
17426 item_instance_id: Some(uuid::Uuid::from_u128(4)),
17427 tile_id: None,
17428 worker_lodging_capacity: None,
17429 blocking: false,
17430 blocking_radius_m: 0.0,
17431 building_id: None,
17432 }];
17433 let nearby = state.nearby_containers();
17434 let label = state.container_volume_label(&nearby[0].rows[0]);
17435 assert!(
17436 label.contains("vol 4/60"),
17437 "expected used/cap in label, got {label}"
17438 );
17439 assert!(
17440 label.contains("56 free"),
17441 "expected free space, got {label}"
17442 );
17443 }
17444
17445 #[test]
17446 fn key_pair_chest_label_from_placed_lock_id() {
17447 let mut state = sample_state();
17448 let owner = uuid::Uuid::from_u128(77);
17449 state.character_id = Some(owner);
17450 let lock = uuid::Uuid::from_u128(99).to_string();
17451 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17452 id: "chest-1".into(),
17453 template_id: "wooden_chest_small".into(),
17454 display_name: "Barry's Loot #a3f2".into(),
17455 x: 129.0,
17456 y: 128.0,
17457 z: 0.0,
17458 locked: true,
17459 accessible: true,
17460 owner_character_id: Some(owner),
17461 contents: Vec::new(),
17462 lock_id: Some(lock.clone()),
17463 capacity_volume: None,
17464 item_instance_id: Some(uuid::Uuid::from_u128(4)),
17465 tile_id: None,
17466 worker_lodging_capacity: None,
17467 blocking: false,
17468 blocking_radius_m: 0.0,
17469 building_id: None,
17470 }];
17471 let key_id = uuid::Uuid::from_u128(5);
17472 let key = flatland_protocol::ItemStack {
17473 template_id: KEY_TEMPLATE.into(),
17474 quantity: 1,
17475 item_instance_id: Some(key_id),
17476 props: BTreeMap::from([
17477 (PROP_OPENS_LOCK_ID.into(), lock),
17478 (
17479 PROP_OPENS_CONTAINER_NAME.into(),
17480 "Barry's Loot #a3f2".into(),
17481 ),
17482 ]),
17483 status_bindings: Vec::new(),
17484 contents: Vec::new(),
17485 display_name: Some("Container Key".into()),
17486 category: Some("key".into()),
17487 base_mass: None,
17488 base_volume: None,
17489 capacity_volume: None,
17490 stackable: None,
17491 world_placeable: None,
17492 worker_lodging_capacity: None,
17493 equip_slot: None,
17494 armor_physical: None,
17495 resists: vec![],
17496 hand_slots: None,
17497 listable: None,
17498 ..Default::default()
17499 };
17500 state.inventory_stacks = vec![key.clone()];
17501 assert_eq!(
17502 state.key_pair_chest_label(&key).as_deref(),
17503 Some("Barry's Loot #a3f2")
17504 );
17505 assert!(state.key_drop_blocked(&key));
17506 }
17507
17508 #[test]
17509 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
17510 let mut state = sample_state();
17511 let lock = uuid::Uuid::from_u128(101).to_string();
17512 let key = flatland_protocol::ItemStack {
17513 template_id: KEY_TEMPLATE.into(),
17514 quantity: 1,
17515 item_instance_id: Some(uuid::Uuid::from_u128(7)),
17516 props: BTreeMap::from([
17517 (PROP_OPENS_LOCK_ID.into(), lock),
17518 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
17519 ]),
17520 status_bindings: Vec::new(),
17521 contents: Vec::new(),
17522 display_name: None,
17523 category: Some("key".into()),
17524 base_mass: None,
17525 base_volume: None,
17526 capacity_volume: None,
17527 stackable: None,
17528 world_placeable: None,
17529 worker_lodging_capacity: None,
17530 equip_slot: None,
17531 armor_physical: None,
17532 resists: vec![],
17533 hand_slots: None,
17534 listable: None,
17535 ..Default::default()
17536 };
17537 state.placed_containers.clear();
17538 assert_eq!(
17539 state.key_pair_chest_label(&key).as_deref(),
17540 Some("Camp Stash")
17541 );
17542 }
17543
17544 #[test]
17545 fn key_drop_allowed_when_paired_chest_unlocked() {
17546 let mut state = sample_state();
17547 let lock = uuid::Uuid::from_u128(100).to_string();
17548 let key_id = uuid::Uuid::from_u128(6);
17549 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17550 id: "chest-1".into(),
17551 template_id: "wooden_chest_small".into(),
17552 display_name: "Camp Chest".into(),
17553 x: 129.0,
17554 y: 128.0,
17555 z: 0.0,
17556 locked: false,
17557 accessible: true,
17558 owner_character_id: None,
17559 contents: Vec::new(),
17560 lock_id: Some(lock.clone()),
17561 capacity_volume: None,
17562 item_instance_id: None,
17563 tile_id: None,
17564 worker_lodging_capacity: None,
17565 blocking: false,
17566 blocking_radius_m: 0.0,
17567 building_id: None,
17568 }];
17569 let key = flatland_protocol::ItemStack {
17570 template_id: KEY_TEMPLATE.into(),
17571 quantity: 1,
17572 item_instance_id: Some(key_id),
17573 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
17574 status_bindings: Vec::new(),
17575 contents: Vec::new(),
17576 display_name: None,
17577 category: Some("key".into()),
17578 base_mass: None,
17579 base_volume: None,
17580 capacity_volume: None,
17581 stackable: None,
17582 world_placeable: None,
17583 worker_lodging_capacity: None,
17584 equip_slot: None,
17585 armor_physical: None,
17586 resists: vec![],
17587 hand_slots: None,
17588 listable: None,
17589 ..Default::default()
17590 };
17591 state.inventory_stacks = vec![key.clone()];
17592 assert!(!state.key_drop_blocked(&key));
17593 let opts = state.move_destinations_for(
17594 &flatland_protocol::InventoryLocation::Root,
17595 None,
17596 Some(key_id),
17597 KEY_TEMPLATE,
17598 );
17599 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
17600 }
17601
17602 #[test]
17603 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
17604 use flatland_protocol::{CombatHud, ProgressionCurve, ProgressionXp};
17605
17606 let mut state = sample_state();
17607 let curve = ProgressionCurve::default();
17608 let bootstrap =
17609 ProgressionXp::bootstrap_new(curve.baseline_display, curve.xp_base, curve.xp_growth);
17610 let mut fresh = bootstrap.clone();
17611 fresh.strength += 0.08;
17612 if let Some(player) = state.player.as_mut() {
17613 player.progression_xp = Some(bootstrap);
17614 }
17615
17616 let combat = CombatHud {
17617 progression_xp: Some(fresh.clone()),
17618 progression_baseline: curve.baseline_display,
17619 progression_xp_base: curve.xp_base,
17620 progression_xp_growth: curve.xp_growth,
17621 attributes: state.player.as_ref().and_then(|p| p.attributes),
17622 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
17623 ..CombatHud::default()
17624 };
17625 state.apply_combat_hud(&combat);
17626
17627 let xp = state
17628 .player
17629 .as_ref()
17630 .and_then(|p| p.progression_xp.as_ref())
17631 .expect("xp");
17632 assert!((xp.strength - fresh.strength).abs() < 0.001);
17633 assert!(state.progression_curve.is_some());
17634 }
17635
17636 #[test]
17637 fn combat_hud_syncs_known_abilities_and_hotbar() {
17638 use flatland_protocol::CombatHud;
17639
17640 let mut state = sample_state();
17641 let combat = CombatHud {
17642 known_abilities: vec!["unarmed".into(), "fireball".into()],
17643 hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
17644 max_abilities_per_rotation: 4,
17645 ability_id: "short_sword_slash".into(),
17646 ..CombatHud::default()
17647 };
17648 state.apply_combat_hud(&combat);
17649
17650 assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
17651 assert_eq!(state.hotbar_ability(1), Some("fireball"));
17652 assert_eq!(state.hotbar_ability(2), None);
17653 assert_eq!(state.hotbar_ability(3), Some("unarmed"));
17654 assert_eq!(state.max_abilities_per_rotation, 4);
17655 let choices = state.loadout_ability_choices();
17656 assert!(choices.iter().any(|a| a == "short_sword_slash"));
17657 assert!(choices.iter().any(|a| a == "fireball"));
17658 }
17659
17660 #[test]
17661 fn loadout_hotbar_choices_include_inventory_consumables() {
17662 let mut state = sample_state();
17663 state.known_abilities = vec!["unarmed".into()];
17664 state.weapon_ability_id = "unarmed".into();
17665 state.inventory_stacks = vec![flatland_protocol::ItemStack {
17666 template_id: "bottle_of_water".into(),
17667 quantity: 3,
17668 item_instance_id: Some(uuid::Uuid::from_u128(9)),
17669 display_name: Some("Bottle of Water".into()),
17670 category: Some("consumable".into()),
17671 ..Default::default()
17672 }];
17673 state.inventory.insert("bottle_of_water".into(), 3);
17674 state.inventory_hints.insert(
17675 "bottle_of_water".into(),
17676 InventoryHint {
17677 display_name: "Bottle of Water".into(),
17678 category: "consumable".into(),
17679 ..Default::default()
17680 },
17681 );
17682
17683 let choices = state.loadout_hotbar_choices();
17684 assert!(choices.iter().any(|c| c.binding == "unarmed"));
17685 let water = choices
17686 .iter()
17687 .find(|c| c.binding == "item:bottle_of_water")
17688 .expect("water binding");
17689 assert_eq!(water.meta.as_deref(), Some("use"));
17690 assert!(water.label.contains("Water"));
17691 assert_eq!(state.hotbar_slot_label(1), None, "unbound until set");
17692 state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
17693 assert_eq!(
17694 state.hotbar_slot_label(5).as_deref(),
17695 Some("Bottle of Water×3")
17696 );
17697 }
17698
17699 #[test]
17700 fn storage_store_options_excludes_hand_equipped() {
17701 let mut state = sample_state();
17702 let sword_id = uuid::Uuid::from_u128(11);
17703 let ore_id = uuid::Uuid::from_u128(22);
17704 state.inventory_stacks = vec![
17705 flatland_protocol::ItemStack {
17706 template_id: "short_sword".into(),
17707 quantity: 1,
17708 item_instance_id: Some(sword_id),
17709 display_name: Some("Short Sword".into()),
17710 category: Some("weapon".into()),
17711 ..Default::default()
17712 },
17713 flatland_protocol::ItemStack {
17714 template_id: "iron_ore".into(),
17715 quantity: 5,
17716 item_instance_id: Some(ore_id),
17717 display_name: Some("Iron Ore".into()),
17718 category: Some("resource".into()),
17719 ..Default::default()
17720 },
17721 ];
17722 state.mainhand_template_id = Some("short_sword".into());
17723 state.mainhand_instance_id = Some(sword_id);
17724
17725 let opts = state.storage_store_options();
17726 assert_eq!(opts.len(), 1);
17727 assert_eq!(opts[0].item_instance_id, ore_id);
17728 assert!(state.hand_equipped_instance_ids().contains(&sword_id));
17729 }
17730
17731 #[test]
17732 fn loose_consumable_move_picker_offers_use_and_storage() {
17733 let mut state = sample_state();
17734 let inst = uuid::Uuid::from_u128(77);
17735 state.inventory_stacks = vec![flatland_protocol::ItemStack {
17736 template_id: "carrot".into(),
17737 quantity: 2,
17738 item_instance_id: Some(inst),
17739 props: Default::default(),
17740 status_bindings: Vec::new(),
17741 contents: Vec::new(),
17742 display_name: Some("Wild Carrot".into()),
17743 category: Some("consumable".into()),
17744 base_mass: None,
17745 base_volume: None,
17746 capacity_volume: None,
17747 stackable: Some(true),
17748 world_placeable: None,
17749 worker_lodging_capacity: None,
17750 equip_slot: None,
17751 armor_physical: None,
17752 resists: vec![],
17753 hand_slots: None,
17754 listable: None,
17755 ..Default::default()
17756 }];
17757 state.inventory_hints.insert(
17758 "carrot".into(),
17759 InventoryHint {
17760 display_name: "Wild Carrot".into(),
17761 category: "consumable".into(),
17762 base_mass: Some(0.15),
17763 base_volume: Some(0.3),
17764 capacity_volume: None,
17765 stackable: true,
17766 listable: true,
17767 base_value_copper: None,
17768 },
17769 );
17770 state.show_inventory_menu = true;
17771 state.inventory_menu_index = 0;
17772
17773 let row = state.inventory_selected_row().expect("carrot row");
17774 let mut options = state.move_destinations_for(
17775 &row.from,
17776 row.from_parent_instance_id,
17777 row.stack.item_instance_id,
17778 &row.stack.template_id,
17779 );
17780 if row.from == flatland_protocol::InventoryLocation::Root
17781 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
17782 {
17783 options.insert(
17784 0,
17785 MoveOption {
17786 label: "Use (eat / drink)".into(),
17787 kind: MoveOptionKind::Use,
17788 },
17789 );
17790 }
17791
17792 assert_eq!(
17793 options.first().map(|o| &o.label),
17794 Some(&"Use (eat / drink)".into())
17795 );
17796 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
17797 assert!(options
17798 .iter()
17799 .any(|o| matches!(o.kind, MoveOptionKind::Drop)));
17800 }
17801
17802 #[test]
17803 fn inventory_category_group_order_is_stable() {
17804 assert_eq!(inventory_category_group("weapon").0, "Weapons");
17805 assert_eq!(inventory_category_group("armor").0, "Armor");
17806 assert_eq!(inventory_category_group("consumable").0, "Consumables");
17807 assert_eq!(inventory_category_group("resource").0, "Resources");
17808 assert_eq!(inventory_category_group("container").0, "Containers");
17809 assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
17810 assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
17811 }
17812
17813 #[test]
17814 fn page_list_index_clamps_without_wrap() {
17815 assert_eq!(page_list_index(0, -1, 25), 0);
17816 assert_eq!(page_list_index(0, 1, 25), 10);
17817 assert_eq!(page_list_index(12, 1, 25), 22);
17818 assert_eq!(page_list_index(22, 1, 25), 24);
17819 assert_eq!(page_list_index(5, 1, 0), 0);
17820 assert_eq!(page_list_index(3, -1, 8), 0);
17821 }
17822
17823 #[test]
17824 fn inventory_filter_hides_non_matching_person_items() {
17825 let mut state = sample_state();
17826 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
17827 sword.display_name = Some("Iron Sword".into());
17828 sword.category = Some("weapon".into());
17829 let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
17830 herb.display_name = Some("Wild Herb".into());
17831 herb.category = Some("consumable".into());
17832 state.inventory_stacks = vec![sword, herb];
17833 state.inventory_tab = InventoryTab::OnPerson;
17834 state.inventory_filter = "sword".into();
17835
17836 let rows = state.inventory_selectable_rows();
17837 assert_eq!(rows.len(), 1);
17838 assert_eq!(rows[0].stack.template_id, "iron_sword");
17839
17840 let lines = state.inventory_browser_lines();
17841 assert!(lines.iter().any(|l| matches!(
17842 l,
17843 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
17844 )));
17845 assert!(!lines.iter().any(|l| matches!(
17846 l,
17847 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
17848 )));
17849 }
17850
17851 #[test]
17852 fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
17853 let mut state = sample_state();
17854 let id_a = uuid::Uuid::from_u128(0xa1);
17855 let id_b = uuid::Uuid::from_u128(0xb2);
17856 let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
17857 sword_a.display_name = Some("Iron Sword".into());
17858 sword_a.category = Some("weapon".into());
17859 sword_a.item_instance_id = Some(id_a);
17860 let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
17861 sword_b.display_name = Some("Iron Sword".into());
17862 sword_b.category = Some("weapon".into());
17863 sword_b.item_instance_id = Some(id_b);
17864 state.inventory_stacks = vec![sword_a, sword_b];
17865 state.inventory_tab = InventoryTab::OnPerson;
17866
17867 let lines = state.inventory_browser_lines();
17868 let items: Vec<_> = lines
17869 .iter()
17870 .filter_map(|l| match l {
17871 InventoryBrowserLine::Item {
17872 title,
17873 instance_tooltip,
17874 ..
17875 } => Some((title.clone(), instance_tooltip.clone())),
17876 _ => None,
17877 })
17878 .collect();
17879 assert_eq!(items.len(), 2);
17880 for (title, tip) in &items {
17881 assert!(
17882 !title.contains('#'),
17883 "title should not show instance suffix: {title}"
17884 );
17885 assert!(
17886 tip.is_some(),
17887 "two identical rows should expose instance on hover"
17888 );
17889 }
17890
17891 state.inventory_stacks.pop();
17892 let lines = state.inventory_browser_lines();
17893 let one = lines.iter().find_map(|l| match l {
17894 InventoryBrowserLine::Item {
17895 title,
17896 instance_tooltip,
17897 ..
17898 } => Some((title.clone(), instance_tooltip.clone())),
17899 _ => None,
17900 });
17901 let (title, tip) = one.expect("one sword row");
17902 assert!(!title.contains('#'));
17903 assert!(tip.is_none(), "single row should not need instance tooltip");
17904 }
17905
17906 #[test]
17907 fn inventory_person_rows_group_by_category() {
17908 let mut state = sample_state();
17909 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
17910 sword.category = Some("weapon".into());
17911 sword.display_name = Some("Iron Sword".into());
17912 let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
17913 ore.category = Some("resource".into());
17914 ore.display_name = Some("Iron Ore".into());
17915 let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
17916 potion.category = Some("consumable".into());
17917 potion.display_name = Some("Health Potion".into());
17918 state.inventory_stacks = vec![ore, potion, sword];
17919 state.inventory_tab = InventoryTab::OnPerson;
17920
17921 let lines = state.inventory_browser_lines();
17922 let labels: Vec<&str> = lines
17923 .iter()
17924 .filter_map(|l| match l {
17925 InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
17926 _ => None,
17927 })
17928 .collect();
17929 assert!(
17930 labels.iter().any(|s| s.contains("Weapons")),
17931 "expected Weapons group: {labels:?}"
17932 );
17933 assert!(labels.iter().any(|s| s.contains("Consumables")));
17934 assert!(labels.iter().any(|s| s.contains("Resources")));
17935
17936 let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
17937 let consumable_pos = labels
17938 .iter()
17939 .position(|s| s.contains("Consumables"))
17940 .unwrap();
17941 let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
17942 assert!(weapon_pos < consumable_pos);
17943 assert!(consumable_pos < resource_pos);
17944 }
17945
17946 #[test]
17947 fn inventory_tab_cycle_resets_selection() {
17948 let mut state = sample_state();
17949 state.inventory_tab = InventoryTab::OnPerson;
17950 state.inventory_menu_index = 3;
17951 state.inventory_tab = state.inventory_tab.cycle(true);
17952 assert_eq!(state.inventory_tab, InventoryTab::Nearby);
17953 assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
17955 assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
17956 assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
17957 assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
17958 }
17959
17960 #[test]
17961 fn parse_bank_copper_amount_blank_and_zero_mean_all() {
17962 assert_eq!(parse_bank_copper_amount(""), Some(0));
17963 assert_eq!(parse_bank_copper_amount(" "), Some(0));
17964 assert_eq!(parse_bank_copper_amount("0"), Some(0));
17965 assert_eq!(parse_bank_copper_amount("250"), Some(250));
17966 assert_eq!(parse_bank_copper_amount("nope"), None);
17967 }
17968
17969 #[test]
17970 fn parse_storage_quantity_blank_and_zero_mean_all() {
17971 assert_eq!(parse_storage_quantity(""), Some(None));
17972 assert_eq!(parse_storage_quantity(" "), Some(None));
17973 assert_eq!(parse_storage_quantity("0"), Some(None));
17974 assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
17975 assert_eq!(parse_storage_quantity("nope"), None);
17976 }
17977
17978 #[test]
17979 fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
17980 assert!(worker_error_is_hud_noise("path stuck — repathing"));
17981 assert!(worker_error_is_hud_noise(
17982 "path stuck — nudged clear, repathing"
17983 ));
17984 assert!(worker_error_is_hud_noise(
17985 "returned to lodging after path failures"
17986 ));
17987 assert!(!worker_error_is_hud_noise(
17989 "path stuck — no lodging to reset to"
17990 ));
17991 }
17992
17993 #[test]
17994 fn leaving_building_restores_outdoor_z_bands() {
17995 use flatland_protocol::{InteriorMapView, ZPlatformView};
17996
17997 let mut state = sample_state();
17998 state.z_platforms.clear();
17999 state.z_transitions.clear();
18000 state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
18001 state.interior_map = Some(InteriorMapView {
18002 building_id: "broker_hut".into(),
18003 blueprint_id: "broker_hut".into(),
18004 background_color: "#000".into(),
18005 default_floor_color: None,
18006 floor_height_m: 3.0,
18007 z_platforms: vec![ZPlatformView {
18008 id: "floor_0".into(),
18009 z: 0.0,
18010 x0: 0.0,
18011 y0: 0.0,
18012 x1: 8.0,
18013 y1: 8.0,
18014 }],
18015 z_transitions: vec![],
18016 rooms: vec![],
18017 room_doors: vec![],
18018 });
18019 state.sync_interior_map_context();
18020 assert_eq!(
18021 state.z_platforms.len(),
18022 1,
18023 "indoors installs interior platforms"
18024 );
18025 assert!(state.z_bands_outdoor_backup.is_some());
18026
18027 state.player.as_mut().unwrap().inside_building = None;
18028 state.sync_interior_map_context();
18029 assert!(
18030 state.z_platforms.is_empty(),
18031 "leaving must restore outdoor bands (empty), not leave interior platforms"
18032 );
18033 assert!(state.z_bands_outdoor_backup.is_none());
18034 assert!(state.interior_map.is_none());
18035 }
18036
18037 #[test]
18038 fn resource_node_route_label_prefers_friendly_label_with_suffix() {
18039 let node = ResourceNodeView {
18040 id: "crop-carrot-1_copy10".into(),
18041 label: "crop-carrot-1_copy10".into(),
18042 x: 0.0,
18043 y: 0.0,
18044 z: 0.0,
18045 item_template: "carrot".into(),
18046 state: ResourceNodeState::Available,
18047 blocking: false,
18048 blocking_radius_m: 0.5,
18049 harvest_off: false,
18050 tile_id: None,
18051 yaw: 0.0,
18052 pitch: 0.0,
18053 roll: 0.0,
18054 draw_scale: 1.0,
18055 sprite_mode: None,
18056 growth_progress: None,
18057 presentation_state: None,
18058 channel_start_tick: None,
18059 channel_end_tick: None,
18060 harvest_drop_templates: vec![],
18061 };
18062 let label = super::resource_node_route_label(&node);
18063 assert!(label.starts_with("Carrot ("), "got {label}");
18064 assert!(label.ends_with(')'), "got {label}");
18065
18066 let mut named = node;
18067 named.label = "Sweet Pad".into();
18068 named.id = "crop-carrot-a3f2b1c0".into();
18069 assert_eq!(super::resource_node_route_label(&named), "Sweet Pad (b1c0)");
18070 }
18071
18072 #[test]
18073 fn plot_public_label_uses_owner_zone_and_label() {
18074 let plot = flatland_protocol::PropertyPlotView {
18075 plot_id: uuid::Uuid::nil(),
18076 property_zone_id: "zone_a".into(),
18077 zone_label: Some("Starter Town East 1".into()),
18078 deed_instance_id: uuid::Uuid::nil(),
18079 x0: 0.0,
18080 y0: 0.0,
18081 x1: 4.0,
18082 y1: 4.0,
18083 upkeep_copper_per_day: 1,
18084 arrears_days: 0,
18085 is_mine: true,
18086 may_farm: true,
18087 purchase_basis_copper: 0,
18088 farm_public: false,
18089 public_tax_discount_bps: 0,
18090 farm_allow: vec![],
18091 owner_character_id: None,
18092 owner_label: Some("Madsin".into()),
18093 building_id: None,
18094 plot_code: "xyz1234a".into(),
18095 label: "Food Pad".into(),
18096 };
18097 assert_eq!(
18098 super::plot_public_label(&plot),
18099 "Madsin — Starter Town East 1 — Food Pad"
18100 );
18101 }
18102}