1use std::collections::{BTreeMap, VecDeque};
2use std::time::{Duration, Instant};
3
4use flatland_protocol::{
5 AbilityCooldownHud, BlueprintView, BodySlot, BuildingView, CastProgressHud, CombatHud,
6 CombatSlotHud, CombatTargetHud, DoorView, EntityId, EntityState, Intent, InteriorMapView,
7 LifeState, NpcView, RotationPreset, Seq, SessionId, TerrainKindView, TerrainZoneView, Tick,
8 ZPlatformView, ZTransitionView,
9};
10
11use crate::session::{PlayConnection, SessionEvent};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum CharacterSheetTab {
15 #[default]
16 Character,
17 Ledger,
18 Career,
19}
20
21impl CharacterSheetTab {
22 pub fn cycle(self) -> Self {
23 match self {
24 Self::Character => Self::Ledger,
25 Self::Ledger => Self::Career,
26 Self::Career => Self::Character,
27 }
28 }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum LedgerPeriod {
33 #[default]
34 Day,
35 Week,
36 Month,
37 Lifetime,
38}
39
40impl LedgerPeriod {
41 pub fn label(self) -> &'static str {
42 match self {
43 Self::Day => "Day",
44 Self::Week => "Week",
45 Self::Month => "Month",
46 Self::Lifetime => "All",
47 }
48 }
49
50 pub fn cycle(self) -> Self {
51 match self {
52 Self::Day => Self::Week,
53 Self::Week => Self::Month,
54 Self::Month => Self::Lifetime,
55 Self::Lifetime => Self::Day,
56 }
57 }
58
59 pub fn from_digit(c: char) -> Option<Self> {
60 match c {
61 '1' => Some(Self::Day),
62 '2' => Some(Self::Week),
63 '3' => Some(Self::Month),
64 '4' => Some(Self::Lifetime),
65 _ => None,
66 }
67 }
68}
69
70const KEY_TEMPLATE: &str = "container_key";
72const PROP_LOCK_ID: &str = "lock_id";
73const PROP_OPENS_LOCK_ID: &str = "opens_lock_id";
74const PROP_OPENS_CONTAINER_NAME: &str = "opens_container_name";
75const PROP_CUSTOM_NAME: &str = "custom_name";
76const PROP_LOCKED: &str = "locked";
77
78fn stack_is_locked(stack: &flatland_protocol::ItemStack) -> bool {
79 stack
80 .props
81 .get(PROP_LOCKED)
82 .is_some_and(|v| v == "true" || v == "1")
83}
84
85const MAX_LOG_LINES: usize = 200;
86const MAX_SHOP_TRADE_LOG_LINES: usize = 40;
87const INTERACTION_RADIUS_M: f32 = 1.5;
88const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
89const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
90const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
91const CRAFT_STAMINA_COST: f32 = 3.0;
93const WORKER_STEP_HOLD: Duration = Duration::from_millis(1200);
95const WORKER_ERROR_HOLD: Duration = Duration::from_secs(45);
97
98#[derive(Debug, Clone, Default)]
100pub struct InventoryHint {
101 pub display_name: String,
102 pub category: String,
103 pub base_mass: Option<f32>,
104 pub base_volume: Option<f32>,
105 pub capacity_volume: Option<f32>,
106 pub stackable: bool,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct LoadoutHotbarChoice {
112 pub binding: String,
114 pub label: String,
116 pub meta: Option<String>,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
122pub enum RotationEditorMode {
123 #[default]
124 List,
125 EditSequence,
126 PickAbility,
127 EditLabel,
128}
129
130#[derive(Debug, Clone, Default)]
132pub struct RotationEditorState {
133 pub mode: RotationEditorMode,
134 pub list_index: usize,
135 pub ability_index: usize,
136 pub picker_index: usize,
137 pub draft: Option<RotationPreset>,
138 pub label_buffer: String,
139}
140
141impl RotationEditorState {
142 pub fn reset(&mut self) {
143 *self = Self::default();
144 }
145}
146
147pub const CONTAINER_RANGE_M: f32 = 3.0;
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum InventorySection {
157 Worn,
159 Person,
161 Nearby,
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
167pub enum InventoryTab {
168 #[default]
169 OnPerson,
170 Nearby,
171}
172
173impl InventoryTab {
174 pub fn label(self) -> &'static str {
175 match self {
176 Self::OnPerson => "On person",
177 Self::Nearby => "Nearby storage",
178 }
179 }
180
181 pub fn cycle(self, forward: bool) -> Self {
182 match (self, forward) {
183 (Self::OnPerson, true) | (Self::OnPerson, false) => Self::Nearby,
184 (Self::Nearby, true) | (Self::Nearby, false) => Self::OnPerson,
185 }
186 }
187}
188
189pub const LIST_PAGE_SIZE: usize = 10;
191
192pub fn list_label_matches(haystack: &str, filter: &str) -> bool {
194 if filter.is_empty() {
195 return true;
196 }
197 haystack
198 .to_ascii_lowercase()
199 .contains(&filter.to_ascii_lowercase())
200}
201
202pub fn page_list_index(index: usize, pages: i32, len: usize) -> usize {
204 if len == 0 {
205 return 0;
206 }
207 let page = LIST_PAGE_SIZE as i32;
208 let next = index as i32 + pages * page;
209 next.clamp(0, (len as i32) - 1) as usize
210}
211
212pub fn step_filtered_index(index: usize, delta: i32, len: usize, pred: impl Fn(usize) -> bool) -> usize {
214 if len == 0 {
215 return 0;
216 }
217 let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
218 if matching.is_empty() {
219 return index.min(len - 1);
220 }
221 let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
222 let next = (pos as i32 + delta).rem_euclid(matching.len() as i32) as usize;
223 matching[next]
224}
225
226pub fn page_filtered_index(
228 index: usize,
229 pages: i32,
230 len: usize,
231 pred: impl Fn(usize) -> bool,
232) -> usize {
233 if len == 0 {
234 return 0;
235 }
236 let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
237 if matching.is_empty() {
238 return index.min(len - 1);
239 }
240 let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
241 let next = page_list_index(pos, pages, matching.len());
242 matching[next]
243}
244
245pub fn inventory_category_group(category: &str) -> (&'static str, u8) {
247 match category {
248 "weapon" | "ammo" => ("Weapons", 0),
249 "armor" | "shield" | "offhand" => ("Armor", 1),
250 "consumable" => ("Consumables", 2),
251 "resource" | "harvest_node" => ("Resources", 3),
252 "container" | "lodging" => ("Containers", 4),
253 "currency" | "key" => ("Currency & keys", 5),
254 "tool" | "misc" | "furniture" | "quest" => ("Gear & misc", 6),
255 _ => ("Other", 7),
256 }
257}
258
259fn parse_bank_copper_amount(input: &str) -> Option<u64> {
261 let s = input.trim();
262 if s.is_empty() {
263 return Some(0);
264 }
265 s.parse::<u64>().ok()
266}
267
268fn parse_storage_quantity(input: &str) -> Option<Option<u32>> {
270 let s = input.trim();
271 if s.is_empty() || s == "0" {
272 return Some(None);
273 }
274 let n = s.parse::<u32>().ok()?;
275 if n == 0 {
276 return Some(None);
277 }
278 Some(Some(n))
279}
280
281fn storage_stack_label(stack: &flatland_protocol::ItemStack) -> String {
282 let name = stack
283 .display_name
284 .as_deref()
285 .unwrap_or(stack.template_id.as_str());
286 if stack.quantity > 1 {
287 format!("{name} ×{}", stack.quantity)
288 } else {
289 name.to_string()
290 }
291}
292
293pub fn body_slot_label(slot: BodySlot) -> &'static str {
296 match slot {
297 BodySlot::Head => "Head",
298 BodySlot::Chest => "Chest",
299 BodySlot::Forearms => "Forearms",
300 BodySlot::Legs => "Legs",
301 BodySlot::Feet => "Feet",
302 BodySlot::Cloak => "Cloak",
303 BodySlot::Back => "Back",
304 BodySlot::Waist => "Waist",
305 BodySlot::Earrings => "Earrings",
306 BodySlot::Necklace => "Necklace",
307 BodySlot::Eyeglasses => "Eyeglasses",
308 BodySlot::RingLeft1 => "Ring L1",
309 BodySlot::RingLeft2 => "Ring L2",
310 BodySlot::RingRight1 => "Ring R1",
311 BodySlot::RingRight2 => "Ring R2",
312 }
313}
314
315fn grant_target_matches_mode(stack: &flatland_protocol::ItemStack, mode: &str) -> bool {
316 let cat = stack.category.as_deref().unwrap_or("");
317 match mode {
318 "while_equipped" => {
319 stack.equip_slot.is_some()
320 || cat == "weapon"
321 || cat == "shield"
322 || cat == "offhand"
323 || cat == "armor"
324 }
325 _ => cat == "weapon" || cat == "ammo" || stack.props.contains_key("weapon_ability_id"),
326 }
327}
328
329fn grant_tags_match(stack: &flatland_protocol::ItemStack, grant_tags: &[&str]) -> bool {
330 if grant_tags.is_empty() {
331 return true;
332 }
333 let target_tags: Vec<&str> = stack
334 .props
335 .get("allowed_enchant_tags")
336 .map(|s| {
337 s.split(',')
338 .map(str::trim)
339 .filter(|t| !t.is_empty())
340 .collect()
341 })
342 .unwrap_or_default();
343 if target_tags.is_empty() {
344 return true;
345 }
346 grant_tags.iter().any(|t| target_tags.contains(t))
347}
348
349pub const DEFAULT_TICK_HZ: u32 = 30;
351
352pub fn format_binding_ttl(
354 binding: &flatland_protocol::ItemStatusBinding,
355 tick: u64,
356 tick_hz: u32,
357) -> String {
358 let Some(expires) = binding.expires_at_tick else {
359 return "permanent".into();
360 };
361 let hz = tick_hz.max(1) as f32;
362 let remaining = expires.saturating_sub(tick) as f32 / hz;
363 if remaining <= 0.0 {
364 return "expired".into();
365 }
366 if remaining >= 120.0 {
367 format!("{:.0}m left", remaining / 60.0)
368 } else if remaining >= 10.0 {
369 format!("{remaining:.0}s left")
370 } else {
371 format!("{remaining:.1}s left")
372 }
373}
374
375pub fn format_binding_mode(mode: flatland_protocol::ItemStatusBindingMode) -> &'static str {
376 match mode {
377 flatland_protocol::ItemStatusBindingMode::OnHit => "on hit",
378 flatland_protocol::ItemStatusBindingMode::WhileEquipped => "while equipped",
379 }
380}
381
382pub fn format_status_bindings_suffix(
384 bindings: &[flatland_protocol::ItemStatusBinding],
385 tick: u64,
386 tick_hz: u32,
387) -> String {
388 if bindings.is_empty() {
389 return String::new();
390 }
391 let parts: Vec<String> = bindings
392 .iter()
393 .map(|b| {
394 format!(
395 "{} ({}, {})",
396 b.effect_id,
397 format_binding_mode(b.mode),
398 format_binding_ttl(b, tick, tick_hz)
399 )
400 })
401 .collect();
402 format!(" · {}", parts.join("; "))
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub enum EquipPaperdollRow {
407 Body { slot: BodySlot, filled: bool },
408 Mainhand { filled: bool },
409 Offhand { filled: bool, locked: bool },
410}
411
412pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
413 let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
414 .iter()
415 .map(|slot| EquipPaperdollRow::Body {
416 slot: *slot,
417 filled: state.worn.contains_key(slot),
418 })
419 .collect();
420 let two_hand = state.mainhand_hand_slots >= 2;
421 rows.push(EquipPaperdollRow::Mainhand {
422 filled: state.mainhand_template_id.is_some(),
423 });
424 rows.push(EquipPaperdollRow::Offhand {
425 filled: state.offhand_template_id.is_some(),
426 locked: two_hand,
427 });
428 rows
429}
430
431fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
432 for stack in &state.inventory_stacks {
433 let matches = stack
434 .equip_slot
435 .map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
436 .unwrap_or(false)
437 || guess_body_slot(&stack.template_id) == Some(slot);
438 if matches {
439 return stack.item_instance_id;
440 }
441 }
442 None
443}
444
445fn is_client_ring(slot: BodySlot) -> bool {
446 matches!(
447 slot,
448 BodySlot::RingLeft1
449 | BodySlot::RingLeft2
450 | BodySlot::RingRight1
451 | BodySlot::RingRight2
452 )
453}
454
455fn first_inventory_weapon(state: &GameState) -> Option<String> {
456 for stack in &state.inventory_stacks {
457 if stack.category.as_deref() == Some("weapon") {
458 return Some(stack.template_id.clone());
459 }
460 }
461 None
462}
463
464fn first_inventory_offhand(state: &GameState) -> Option<String> {
465 for stack in &state.inventory_stacks {
466 let cat = stack.category.as_deref().unwrap_or("");
467 if matches!(cat, "shield" | "offhand") {
468 return Some(stack.template_id.clone());
469 }
470 }
471 None
472}
473
474fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
477 if template_id.contains("backpack") {
478 Some(BodySlot::Back)
479 } else if template_id.contains("belt") {
480 Some(BodySlot::Waist)
481 } else if template_id.contains("cloak") || template_id.contains("cape") {
482 Some(BodySlot::Cloak)
483 } else if template_id.contains("cap")
484 || template_id.contains("hat")
485 || template_id.contains("helm")
486 {
487 Some(BodySlot::Head)
488 } else if template_id.contains("shirt")
489 || template_id.contains("robe")
490 || template_id.contains("vest")
491 || template_id.contains("chest")
492 || template_id.contains("jerkin")
493 {
494 Some(BodySlot::Chest)
495 } else if template_id.contains("sleeves")
496 || template_id.contains("gloves")
497 || template_id.contains("gauntlets")
498 {
499 Some(BodySlot::Forearms)
500 } else if template_id.contains("pants") || template_id.contains("leggings") {
501 Some(BodySlot::Legs)
502 } else if template_id.contains("boots") || template_id.contains("shoes") {
503 Some(BodySlot::Feet)
504 } else if template_id.contains("earring") {
505 Some(BodySlot::Earrings)
506 } else if template_id.contains("necklace") || template_id.contains("amulet") {
507 Some(BodySlot::Necklace)
508 } else if template_id.contains("glass")
509 || template_id.contains("spectacles")
510 || template_id.contains("goggles")
511 {
512 Some(BodySlot::Eyeglasses)
513 } else if template_id.contains("ring") {
514 Some(BodySlot::RingLeft1)
515 } else {
516 None
517 }
518}
519
520#[derive(Debug, Clone)]
522pub struct InventoryRow {
523 pub depth: usize,
524 pub stack: flatland_protocol::ItemStack,
525 pub from: flatland_protocol::InventoryLocation,
527 pub from_parent_instance_id: Option<uuid::Uuid>,
529 pub is_equip_shell: bool,
531 pub is_chest_shell: bool,
533 pub section: InventorySection,
534}
535
536#[derive(Debug, Clone)]
538pub struct InventoryRowView {
539 pub depth: usize,
540 pub text: String,
542 pub title: String,
544 pub mass_kg: Option<f32>,
545 pub volume: Option<(f32, f32)>,
546}
547
548#[derive(Debug, Clone)]
550pub enum InventoryBrowserLine {
551 Section(String),
552 SlotLabel(String),
553 Hint(String),
554 Blank,
555 Item {
556 selectable_index: usize,
557 selected: bool,
558 depth: usize,
559 text: String,
560 title: String,
561 mass_kg: Option<f32>,
562 volume: Option<(f32, f32)>,
563 },
564}
565
566#[derive(Debug, Clone, PartialEq, Eq, Default)]
568pub enum BankUiMode {
569 #[default]
570 Menu,
571 DepositAmount {
572 input: String,
573 },
574 WithdrawAmount {
575 input: String,
576 },
577 TransferName {
578 input: String,
579 },
580 TransferAmount {
581 to_name: String,
582 input: String,
583 },
584}
585
586#[derive(Debug, Clone, PartialEq, Eq, Default)]
588pub enum StorageUiMode {
589 #[default]
590 Menu,
591 StorePick {
593 index: usize,
594 },
595 StoreAmount {
597 pick_index: usize,
598 item_instance_id: uuid::Uuid,
599 label: String,
600 max_qty: u32,
601 input: String,
602 },
603 TakePick {
605 index: usize,
606 },
607 TakeAmount {
609 pick_index: usize,
610 item_instance_id: uuid::Uuid,
611 label: String,
612 max_qty: u32,
613 input: String,
614 },
615 ShipPick {
617 dest_building_id: String,
618 dest_label: String,
619 index: usize,
620 },
621 ShipAmount {
623 dest_building_id: String,
624 dest_label: String,
625 pick_index: usize,
626 item_instance_id: uuid::Uuid,
627 label: String,
628 max_qty: u32,
629 input: String,
630 },
631}
632
633#[derive(Debug, Clone)]
635pub struct StoragePickOption {
636 pub item_instance_id: uuid::Uuid,
637 pub label: String,
638 pub quantity: u32,
639}
640
641#[derive(Debug, Clone)]
644pub struct NearbyContainer {
645 pub view: flatland_protocol::PlacedContainerView,
646 pub distance_m: f32,
647 pub rows: Vec<InventoryRow>,
648}
649
650#[derive(Debug, Clone)]
652pub struct KeychainEntry {
653 pub stack: flatland_protocol::ItemStack,
654 pub stowed: bool,
655}
656
657#[derive(Debug, Clone)]
659pub struct MoveOption {
660 pub label: String,
661 pub kind: MoveOptionKind,
662}
663
664#[derive(Debug, Clone, PartialEq)]
665pub enum MoveOptionKind {
666 Move {
667 location: flatland_protocol::InventoryLocation,
668 parent_instance_id: Option<uuid::Uuid>,
669 },
670 PickupPlaced {
672 container_id: String,
673 nest_location: flatland_protocol::InventoryLocation,
674 nest_parent_instance_id: Option<uuid::Uuid>,
675 },
676 Use,
678 GrantApply,
680 Drop,
681 Cancel,
682}
683
684#[derive(Debug, Clone)]
686pub struct GrantTargetPicker {
687 pub grant_instance_id: uuid::Uuid,
688 pub grant_label: String,
689 pub effect_id: String,
690 pub mode: String,
691 pub options: Vec<GrantTargetOption>,
692 pub filter: String,
693 pub filter_focused: bool,
694}
695
696#[derive(Debug, Clone)]
697pub struct GrantTargetOption {
698 pub label: String,
699 pub target_instance_id: uuid::Uuid,
700}
701
702#[derive(Debug, Clone)]
704pub struct MovePicker {
705 pub item_instance_id: uuid::Uuid,
706 pub from: flatland_protocol::InventoryLocation,
707 pub item_label: String,
708 pub template_id: String,
709 pub stack_quantity: u32,
710 pub quantity: u32,
711 pub options: Vec<MoveOption>,
712 pub filter: String,
713 pub filter_focused: bool,
714}
715
716#[derive(Debug, Clone)]
718pub struct DestroyPicker {
719 pub item_instance_id: uuid::Uuid,
720 pub from: flatland_protocol::InventoryLocation,
721 pub item_label: String,
722 pub stack_quantity: u32,
723 pub quantity: u32,
724}
725
726#[derive(Debug, Clone)]
728pub struct WorkerGiveOption {
729 pub item_instance_id: uuid::Uuid,
730 pub label: String,
731 pub quantity: u32,
732 pub template_id: String,
733}
734
735#[derive(Debug, Clone)]
737pub struct WorkerGivePicker {
738 pub worker_instance_id: String,
739 pub worker_label: String,
740 pub options: Vec<WorkerGiveOption>,
741}
742
743#[derive(Debug, Clone)]
745pub struct WorkerGiveTargetOption {
746 pub instance_id: String,
747 pub label: String,
748 pub distance_m: f32,
749}
750
751#[derive(Debug, Clone)]
753pub struct WorkerGiveTargetPicker {
754 pub item_instance_id: uuid::Uuid,
755 pub item_label: String,
756 pub quantity: Option<u32>,
757 pub options: Vec<WorkerGiveTargetOption>,
758}
759
760#[derive(Debug, Clone)]
762pub struct WorkerTakePicker {
763 pub worker_instance_id: String,
764 pub worker_label: String,
765 pub options: Vec<WorkerGiveOption>,
766}
767
768pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
770
771#[derive(Debug, Clone)]
773pub struct WorkerTeachOption {
774 pub blueprint_id: String,
775 pub label: String,
776 pub cost_copper: u64,
777 pub min_level: u32,
778 pub worker_level: u32,
779 pub can_afford: bool,
780 pub level_ok: bool,
781}
782
783#[derive(Debug, Clone)]
785pub struct WorkerTeachPicker {
786 pub worker_instance_id: String,
787 pub worker_label: String,
788 pub worker_level: u32,
789 pub options: Vec<WorkerTeachOption>,
790}
791
792#[derive(Debug, Clone, Default)]
795pub struct StickyWorkerStep {
796 shown: String,
797 pending: String,
798 pending_since: Option<Instant>,
799}
800
801impl StickyWorkerStep {
802 fn from_label(label: String) -> Self {
803 Self {
804 shown: label.clone(),
805 pending: label,
806 pending_since: Some(Instant::now()),
807 }
808 }
809
810 fn observe(&mut self, label: &str, now: Instant) {
811 let pending_since = self.pending_since.unwrap_or(now);
812 if label == self.pending {
813 if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD
814 {
815 self.shown = self.pending.clone();
816 }
817 return;
818 }
819 self.pending = label.to_string();
820 self.pending_since = Some(now);
821 if self.shown.is_empty() {
823 self.shown = self.pending.clone();
824 }
825 }
826}
827
828#[derive(Debug, Clone, Default)]
831pub struct StickyWorkerError {
832 message: String,
833 last_seen: Option<Instant>,
834}
835
836impl StickyWorkerError {
837 fn observe(&mut self, err: Option<&str>, now: Instant) {
838 if let Some(e) = err {
839 if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
840 self.message = e.to_string();
841 self.last_seen = Some(now);
842 }
843 return;
844 }
845 if let Some(seen) = self.last_seen {
846 if now.duration_since(seen) > WORKER_ERROR_HOLD {
847 self.message.clear();
848 self.last_seen = None;
849 }
850 }
851 }
852
853 pub fn shown(&self, now: Instant) -> Option<&str> {
854 if self.message.is_empty() {
855 return None;
856 }
857 let seen = self.last_seen?;
858 if now.duration_since(seen) > WORKER_ERROR_HOLD {
859 return None;
860 }
861 Some(self.message.as_str())
862 }
863}
864
865pub fn worker_attention_line(state: &GameState) -> Option<String> {
868 use flatland_protocol::WorkerStateView;
869 let now = Instant::now();
870 for w in &state.hired_workers {
871 if matches!(w.state, WorkerStateView::Strike) {
872 return Some(format!(
873 "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
874 w.label
875 ));
876 }
877 if let Some(err) = state
878 .worker_error_display
879 .get(&w.instance_id)
880 .and_then(|s| s.shown(now))
881 {
882 if !worker_error_is_hud_noise(err) {
883 return Some(format!("Worker {}: {err}", w.label));
884 }
885 }
886 if let Some(err) = &w.last_error {
887 if !worker_error_is_transient(err) && !worker_error_is_hud_noise(err) {
888 return Some(format!("Worker {}: {err}", w.label));
889 }
890 }
891 }
892 None
893}
894
895pub fn worker_error_is_transient(err: &str) -> bool {
897 let e = err.to_ascii_lowercase();
898 e.contains("continuing route")
899 || e.contains("storage full")
900 || e.starts_with("nothing to withdraw")
901}
902
903pub fn worker_error_is_hud_noise(err: &str) -> bool {
906 let e = err.to_ascii_lowercase();
907 e.contains("returned to lodging after path")
908 || e.contains("path failure")
909 || e.contains("no path to")
910 || e.contains("pathfinding")
911 || e.contains("repathing")
913 || e.contains("nudged clear")
914}
915
916#[derive(Debug, Clone)]
918pub struct PendingWorkerJobAck {
919 pub seq: u32,
920 pub worker_instance_id: String,
921 pub worker_label: String,
922 pub idle: bool,
923 pub stop_count: usize,
924 pub prev_route: Option<flatland_protocol::WorkerRouteView>,
925 pub prev_mode: flatland_protocol::WorkerModeView,
926 pub prev_step_label: String,
927 pub prev_last_error: Option<String>,
928}
929
930fn push_inventory_rows(
931 rows: &mut Vec<InventoryRow>,
932 depth: usize,
933 stack: &flatland_protocol::ItemStack,
934 from: &flatland_protocol::InventoryLocation,
935 from_parent_instance_id: Option<uuid::Uuid>,
936 section: InventorySection,
937) {
938 push_inventory_rows_filtered(
939 rows,
940 depth,
941 stack,
942 from,
943 from_parent_instance_id,
944 section,
945 "",
946 );
947}
948
949fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
950 if filter.is_empty() {
951 return true;
952 }
953 let f = filter.to_ascii_lowercase();
954 let name = stack
955 .display_name
956 .as_deref()
957 .unwrap_or("")
958 .to_ascii_lowercase();
959 let tid = stack.template_id.to_ascii_lowercase();
960 name.contains(&f)
961 || tid.contains(&f)
962 || stack
963 .contents
964 .iter()
965 .any(|c| stack_matches_filter(c, filter))
966}
967
968fn push_inventory_rows_filtered(
969 rows: &mut Vec<InventoryRow>,
970 depth: usize,
971 stack: &flatland_protocol::ItemStack,
972 from: &flatland_protocol::InventoryLocation,
973 from_parent_instance_id: Option<uuid::Uuid>,
974 section: InventorySection,
975 filter: &str,
976) {
977 if !filter.is_empty() && !stack_matches_filter(stack, filter) {
978 return;
979 }
980 let self_hit = filter.is_empty() || {
981 let f = filter.to_ascii_lowercase();
982 let name = stack
983 .display_name
984 .as_deref()
985 .unwrap_or("")
986 .to_ascii_lowercase();
987 let tid = stack.template_id.to_ascii_lowercase();
988 name.contains(&f) || tid.contains(&f)
989 };
990 rows.push(InventoryRow {
991 depth,
992 stack: stack.clone(),
993 from: from.clone(),
994 from_parent_instance_id,
995 is_equip_shell: false,
996 is_chest_shell: false,
997 section,
998 });
999 for child in &stack.contents {
1000 if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1001 push_inventory_rows_filtered(
1002 rows,
1003 depth + 1,
1004 child,
1005 from,
1006 stack.item_instance_id,
1007 section,
1008 if self_hit { "" } else { filter },
1009 );
1010 }
1011 }
1012}
1013
1014#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1015pub enum ShopTab {
1016 #[default]
1017 Buy,
1018 Sell,
1019}
1020
1021#[derive(Debug, Clone)]
1022pub struct NpcChatState {
1023 pub npc_id: String,
1024 pub npc_label: String,
1025 pub lines: Vec<String>,
1026 pub input: String,
1027 pub pending: bool,
1028 pub talk_depth: flatland_protocol::NpcTalkDepth,
1029 pub trade_allowed: bool,
1030 pub banner: Option<String>,
1031}
1032
1033impl Default for NpcChatState {
1034 fn default() -> Self {
1035 Self {
1036 npc_id: String::new(),
1037 npc_label: String::new(),
1038 lines: Vec::new(),
1039 input: String::new(),
1040 pending: false,
1041 talk_depth: flatland_protocol::NpcTalkDepth::Full,
1042 trade_allowed: true,
1043 banner: None,
1044 }
1045 }
1046}
1047
1048#[derive(Debug, Clone)]
1049pub struct GameState {
1050 pub session_id: SessionId,
1051 pub entity_id: EntityId,
1052 pub character_id: Option<uuid::Uuid>,
1054 pub tick: Tick,
1055 pub chunk_rev: u64,
1056 pub content_rev: u64,
1057 pub publish_rev: u64,
1058 pub entities: Vec<EntityState>,
1059 pub player: Option<EntityState>,
1060 pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1061 pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1062 pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1063 pub buildings: Vec<BuildingView>,
1064 pub doors: Vec<DoorView>,
1065 pub interior_map: Option<InteriorMapView>,
1066 pub npcs: Vec<NpcView>,
1067 pub blueprints: Vec<BlueprintView>,
1068 pub world_width_m: f32,
1069 pub world_height_m: f32,
1070 pub terrain_zones: Vec<TerrainZoneView>,
1071 pub z_platforms: Vec<ZPlatformView>,
1072 pub z_transitions: Vec<ZTransitionView>,
1073 pub world_clock: flatland_protocol::WorldClock,
1074 pub inventory: std::collections::HashMap<String, u32>,
1075 pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1076 pub logs: VecDeque<String>,
1077 pub intents_sent: u64,
1078 pub ticks_received: u64,
1079 pub connected: bool,
1080 pub disconnect_reason: Option<String>,
1081 pub show_stats: bool,
1082 pub hud_log_hidden: bool,
1084 pub show_equip_menu: bool,
1085 pub equip_menu_index: usize,
1086 pub show_craft_menu: bool,
1087 pub craft_menu_index: usize,
1088 pub craft_batch_quantity: u32,
1090 pub show_shop_menu: bool,
1091 pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1092 pub bank_panel: Option<flatland_protocol::BankPanel>,
1093 pub bank_menu_index: usize,
1094 pub bank_ui_mode: BankUiMode,
1095 pub storage_panel: Option<flatland_protocol::StoragePanel>,
1096 pub storage_menu_index: usize,
1097 pub storage_ui_mode: StorageUiMode,
1098 pub shop_tab: ShopTab,
1099 pub shop_menu_index: usize,
1100 pub shop_quantity: u32,
1101 pub shop_trade_log: VecDeque<String>,
1103 pub show_npc_verb_menu: bool,
1104 pub npc_verb_target: Option<String>,
1105 pub npc_verb_index: usize,
1106 pub player_verbs: crate::social::PlayerVerbState,
1108 pub social_chat: crate::social::SocialChatState,
1109 pub trade_ui: crate::social::TradeUiState,
1110 pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1111 pub show_npc_chat: bool,
1112 pub npc_chat: Option<NpcChatState>,
1113 pub show_inventory_menu: bool,
1114 pub inventory_menu_index: usize,
1115 pub inventory_tab: InventoryTab,
1116 pub inventory_filter: String,
1117 pub inventory_filter_focused: bool,
1118 pub show_move_picker: bool,
1119 pub move_picker_index: usize,
1120 pub move_picker: Option<MovePicker>,
1121 pub show_grant_picker: bool,
1122 pub grant_picker_index: usize,
1123 pub grant_picker: Option<GrantTargetPicker>,
1124 pub show_destroy_picker: bool,
1125 pub destroy_confirm_pending: bool,
1126 pub destroy_picker: Option<DestroyPicker>,
1127 pub show_rename_prompt: bool,
1129 pub show_worker_rename: bool,
1131 pub rename_buffer: String,
1132 pub combat_target: Option<EntityId>,
1134 pub combat_target_label: Option<String>,
1135 pub combat_fx: Vec<flatland_protocol::CombatFx>,
1137 pub in_combat: bool,
1138 pub auto_attack: bool,
1139 pub combat_has_los: bool,
1140 pub attack_cd_ticks: u64,
1141 pub gcd_ticks: u64,
1142 pub weapon_ability_id: String,
1143 pub mainhand_template_id: Option<String>,
1144 pub mainhand_label: Option<String>,
1145 pub offhand_template_id: Option<String>,
1146 pub offhand_label: Option<String>,
1147 pub mainhand_hand_slots: u8,
1148 pub defense: Option<flatland_protocol::DefenseHud>,
1149 pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1151 pub carry_mass: f32,
1152 pub carry_mass_max: f32,
1153 pub encumbrance: flatland_protocol::EncumbranceState,
1154 pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1156 pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1158 pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1160 pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1162 pub combat_target_detail: Option<CombatTargetHud>,
1163 pub cast_progress: Option<CastProgressHud>,
1164 pub ability_cooldowns: Vec<AbilityCooldownHud>,
1165 pub blocking_active: bool,
1166 pub max_target_slots: u8,
1167 pub combat_slots: Vec<CombatSlotHud>,
1168 pub rotation_presets: Vec<RotationPreset>,
1169 pub known_abilities: Vec<String>,
1171 pub hotbar: Vec<Option<String>>,
1173 pub max_abilities_per_rotation: u8,
1175 pub show_loadout_menu: bool,
1176 pub show_keychain_menu: bool,
1177 pub keychain_menu_index: usize,
1178 pub show_rotation_editor: bool,
1179 pub loadout_menu_index: usize,
1181 pub loadout_hotbar_slot: u8,
1183 pub loadout_ability_index: usize,
1185 pub loadout_focus_presets: bool,
1187 pub rotation_editor: RotationEditorState,
1188 pub harvest_in_progress: bool,
1190 pub harvest_started_at: Option<Instant>,
1192 pub pending_craft_ack: Option<(u32, String, u32)>,
1194 pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1195 pub interactables: Vec<flatland_protocol::InteractableView>,
1196 pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1197 pub career: Option<flatland_protocol::PlayerCareerView>,
1198 pub character_sheet_tab: CharacterSheetTab,
1199 pub ledger_period: LedgerPeriod,
1200 pub show_quest_offer: bool,
1201 pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1202 pub show_quest_menu: bool,
1203 pub quest_menu_index: usize,
1204 pub quest_withdraw_confirm: bool,
1205 pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1206 pub show_workers_menu: bool,
1207 pub workers_menu_index: usize,
1208 pub workers_menu_compact: bool,
1210 pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1213 pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1215 pub show_worker_give_picker: bool,
1217 pub worker_give_picker_index: usize,
1218 pub worker_give_picker: Option<WorkerGivePicker>,
1219 pub show_worker_give_target_picker: bool,
1221 pub worker_give_target_picker_index: usize,
1222 pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1223 pub show_worker_take_picker: bool,
1225 pub worker_take_picker_index: usize,
1226 pub worker_take_picker: Option<WorkerTakePicker>,
1227 pub show_worker_teach_picker: bool,
1229 pub worker_teach_picker_index: usize,
1230 pub worker_teach_picker: Option<WorkerTeachPicker>,
1231 pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1233 pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1235 pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1237}
1238
1239impl GameState {
1240 pub fn push_log(&mut self, line: impl Into<String>) {
1241 self.logs.push_back(line.into());
1242 while self.logs.len() > MAX_LOG_LINES {
1243 self.logs.pop_front();
1244 }
1245 }
1246
1247 pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1248 self.shop_trade_log.push_back(line.into());
1249 while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1250 self.shop_trade_log.pop_front();
1251 }
1252 }
1253
1254 pub fn clear_shop_trade_log(&mut self) {
1255 self.shop_trade_log.clear();
1256 }
1257
1258 fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1259 if !self.show_shop_menu {
1260 return;
1261 }
1262 let msg = notice.message.trim();
1263 if msg.is_empty() {
1264 return;
1265 }
1266 if notice.coins_delta != 0
1267 || msg.starts_with("Bought ")
1268 || msg.starts_with("Sold ")
1269 || msg.contains("taught you how to craft")
1270 || msg.starts_with("need ")
1271 {
1272 self.push_shop_trade_log(msg);
1273 }
1274 }
1275
1276 pub fn is_alive(&self) -> bool {
1277 self.player
1278 .as_ref()
1279 .and_then(|p| p.vitals)
1280 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1281 .unwrap_or(true)
1282 }
1283
1284 pub fn npc_verb_options(&self) -> Vec<&'static str> {
1286 let Some(ref id) = self.npc_verb_target else {
1287 return vec![];
1288 };
1289 let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1290 return vec!["Talk"];
1291 };
1292 let role = npc.role.as_str();
1293 if Self::npc_role_is_bank(role) {
1294 return vec!["Bank", "Talk"];
1295 }
1296 if Self::npc_role_is_storage(role) {
1297 return vec!["Storage", "Talk"];
1298 }
1299 if npc.can_trade || Self::npc_role_can_trade(role) {
1300 vec!["Talk", "Trade"]
1301 } else {
1302 vec!["Talk"]
1303 }
1304 }
1305
1306 fn npc_role_can_trade(role: &str) -> bool {
1307 matches!(role, "broker" | "cook" | "farmer" | "merchant")
1308 }
1309
1310 fn npc_role_is_bank(role: &str) -> bool {
1311 role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1312 }
1313
1314 fn npc_role_is_storage(role: &str) -> bool {
1315 role.eq_ignore_ascii_case("storage_manager")
1316 }
1317
1318 pub fn bank_menu_options(&self) -> Vec<&'static str> {
1319 vec![
1320 "Deposit…",
1321 "Withdraw…",
1322 "Deposit all",
1323 "Withdraw all",
1324 "Transfer…",
1325 ]
1326 }
1327
1328 pub fn storage_menu_options(&self) -> Vec<String> {
1329 let mut opts = vec!["Store…".into(), "Take…".into()];
1330 if let Some(panel) = &self.storage_panel {
1331 for dest in &panel.ship_destinations {
1332 opts.push(format!(
1333 "Ship → {} ({} cp / {} ticks)",
1334 dest.label, dest.fee_copper, dest.travel_ticks
1335 ));
1336 }
1337 }
1338 opts
1339 }
1340
1341 pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1343 self.person_rows()
1344 .into_iter()
1345 .filter(|r| r.depth == 0)
1346 .filter_map(|r| {
1347 let id = r.stack.item_instance_id?;
1348 Some(StoragePickOption {
1349 item_instance_id: id,
1350 label: storage_stack_label(&r.stack),
1351 quantity: r.stack.quantity,
1352 })
1353 })
1354 .collect()
1355 }
1356
1357 pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1359 let Some(panel) = &self.storage_panel else {
1360 return Vec::new();
1361 };
1362 panel
1363 .contents
1364 .iter()
1365 .filter_map(|s| {
1366 let id = s.item_instance_id?;
1367 Some(StoragePickOption {
1368 item_instance_id: id,
1369 label: storage_stack_label(s),
1370 quantity: s.quantity,
1371 })
1372 })
1373 .collect()
1374 }
1375
1376 pub fn clear_harvest_state(&mut self) {
1377 self.harvest_in_progress = false;
1378 self.harvest_started_at = None;
1379 }
1380
1381 fn harvest_state_stale(&self) -> bool {
1382 match self.harvest_started_at {
1383 Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
1384 None => self.harvest_in_progress,
1385 }
1386 }
1387
1388 pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
1389 self.player.as_ref().and_then(|p| p.vitals)
1390 }
1391
1392 pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
1393 let materials_ok = blueprint.inputs.iter().all(|input| {
1394 self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
1395 });
1396 let tools_ok = blueprint
1397 .required_tools
1398 .iter()
1399 .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
1400 let station_ok = match blueprint.station.as_deref() {
1401 None | Some("hand") => true,
1402 Some(tag) => self.player_at_station_tag(tag),
1403 };
1404 materials_ok && tools_ok && station_ok
1405 }
1406
1407 pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
1408 if !self.can_craft_blueprint(blueprint) {
1409 return 0;
1410 }
1411 let mut limit = u32::MAX;
1412 for input in &blueprint.inputs {
1413 if input.quantity == 0 {
1414 continue;
1415 }
1416 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1417 limit = limit.min(have / input.quantity);
1418 }
1419 for tool in &blueprint.required_tools {
1420 if tool.consumed {
1421 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1422 limit = limit.min(have);
1423 }
1424 }
1425 let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
1426 if CRAFT_STAMINA_COST > 0.0 {
1427 limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
1428 }
1429 limit
1430 }
1431
1432 pub fn clamp_craft_batch_quantity(&mut self) {
1433 let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
1434 self.craft_batch_quantity = 1;
1435 return;
1436 };
1437 let max = self.max_craft_batches(bp).max(1);
1438 self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
1439 }
1440
1441 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
1442 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1443 return;
1444 };
1445 let max = self.max_craft_batches(&bp).max(1);
1446 let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
1447 self.craft_batch_quantity = next as u32;
1448 }
1449
1450 pub fn craft_batch_set_max(&mut self) {
1451 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1452 return;
1453 };
1454 let max = self.max_craft_batches(&bp);
1455 self.craft_batch_quantity = if max == 0 { 1 } else { max };
1456 }
1457
1458 pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
1459 let preserve_ui = self.show_shop_menu;
1460 let tab = self.shop_tab;
1461 let index = self.shop_menu_index;
1462 let qty = self.shop_quantity;
1463
1464 self.show_shop_menu = true;
1465 self.bank_panel = None;
1466 self.show_craft_menu = false;
1467 self.show_inventory_menu = false;
1468 self.show_stats = false;
1469 if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
1470 self.npc_verb_target = Some(catalog.npc_id.clone());
1471 }
1472 self.shop_catalog = Some(catalog);
1473
1474 if preserve_ui {
1475 self.shop_tab = tab;
1476 self.shop_menu_index = index;
1477 self.shop_quantity = qty;
1478 } else {
1479 self.shop_tab = ShopTab::Buy;
1480 self.shop_menu_index = 0;
1481 self.shop_quantity = 1;
1482 self.clear_shop_trade_log();
1483 }
1484 self.show_npc_verb_menu = false;
1485 self.clamp_shop_selection();
1486 }
1487
1488 pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
1489 let same_teller = self
1490 .bank_panel
1491 .as_ref()
1492 .is_some_and(|p| p.npc_id == panel.npc_id);
1493 self.bank_panel = Some(panel);
1494 self.storage_panel = None;
1495 self.shop_catalog = None;
1496 self.show_shop_menu = false;
1497 self.show_craft_menu = false;
1498 self.show_inventory_menu = false;
1499 self.show_stats = false;
1500 self.show_npc_verb_menu = false;
1501 self.show_npc_chat = false;
1502 self.npc_chat = None;
1503 if !same_teller {
1504 self.bank_menu_index = 0;
1505 self.bank_ui_mode = BankUiMode::Menu;
1506 }
1507 if let Some(panel) = &self.bank_panel {
1508 if self.npc_verb_target.is_none() {
1509 self.npc_verb_target = Some(panel.npc_id.clone());
1510 }
1511 }
1512 }
1513
1514 pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
1515 let same_manager = self
1516 .storage_panel
1517 .as_ref()
1518 .is_some_and(|p| p.npc_id == panel.npc_id);
1519 self.storage_panel = Some(panel);
1520 self.bank_panel = None;
1521 self.bank_ui_mode = BankUiMode::Menu;
1522 self.shop_catalog = None;
1523 self.show_shop_menu = false;
1524 self.show_craft_menu = false;
1525 self.show_inventory_menu = false;
1526 self.show_stats = false;
1527 self.show_npc_verb_menu = false;
1528 self.show_npc_chat = false;
1529 self.npc_chat = None;
1530 if !same_manager {
1531 self.storage_menu_index = 0;
1532 self.storage_ui_mode = StorageUiMode::Menu;
1533 } else {
1534 self.clamp_storage_pick_index();
1535 }
1536 if let Some(panel) = &self.storage_panel {
1537 if self.npc_verb_target.is_none() {
1538 self.npc_verb_target = Some(panel.npc_id.clone());
1539 }
1540 }
1541 }
1542
1543 pub fn clear_bank_panel(&mut self) {
1544 self.bank_panel = None;
1545 self.bank_menu_index = 0;
1546 self.bank_ui_mode = BankUiMode::Menu;
1547 }
1548
1549 pub fn clear_storage_panel(&mut self) {
1550 self.storage_panel = None;
1551 self.storage_menu_index = 0;
1552 self.storage_ui_mode = StorageUiMode::Menu;
1553 }
1554
1555 fn clamp_storage_pick_index(&mut self) {
1556 match &self.storage_ui_mode {
1557 StorageUiMode::StorePick { index } => {
1558 let n = self.storage_store_options().len();
1559 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1560 self.storage_ui_mode = StorageUiMode::StorePick { index: next };
1561 }
1562 StorageUiMode::TakePick { index } => {
1563 let n = self.storage_vault_options().len();
1564 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1565 self.storage_ui_mode = StorageUiMode::TakePick { index: next };
1566 }
1567 StorageUiMode::ShipPick {
1568 dest_building_id,
1569 dest_label,
1570 index,
1571 } => {
1572 let n = self.storage_vault_options().len();
1573 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1574 self.storage_ui_mode = StorageUiMode::ShipPick {
1575 dest_building_id: dest_building_id.clone(),
1576 dest_label: dest_label.clone(),
1577 index: next,
1578 };
1579 }
1580 StorageUiMode::Menu
1581 | StorageUiMode::StoreAmount { .. }
1582 | StorageUiMode::TakeAmount { .. }
1583 | StorageUiMode::ShipAmount { .. } => {}
1584 }
1585 }
1586
1587 pub fn shop_list_len(&self) -> usize {
1588 let Some(catalog) = &self.shop_catalog else {
1589 return 0;
1590 };
1591 match self.shop_tab {
1592 ShopTab::Buy => catalog.sells.len(),
1593 ShopTab::Sell => catalog.buys.len(),
1594 }
1595 }
1596
1597 pub fn shop_menu_move(&mut self, delta: i32) {
1598 let n = self.shop_list_len();
1599 if n == 0 {
1600 return;
1601 }
1602 let idx = self.shop_menu_index as i32;
1603 let next = (idx + delta).rem_euclid(n as i32);
1604 self.shop_menu_index = next as usize;
1605 self.clamp_shop_quantity();
1606 }
1607
1608 pub fn shop_quantity_adjust(&mut self, delta: i32) {
1609 let max = self.shop_quantity_max();
1610 if max == 0 {
1611 self.shop_quantity = 0;
1612 return;
1613 }
1614 let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
1615 self.shop_quantity = next as u32;
1616 }
1617
1618 pub(crate) fn clamp_shop_selection(&mut self) {
1619 let n = self.shop_list_len();
1620 if n == 0 {
1621 self.shop_menu_index = 0;
1622 } else {
1623 self.shop_menu_index = self.shop_menu_index.min(n - 1);
1624 }
1625 self.clamp_shop_quantity();
1626 }
1627
1628 fn shop_quantity_max(&self) -> u32 {
1629 let Some(catalog) = &self.shop_catalog else {
1630 return 1;
1631 };
1632 match self.shop_tab {
1633 ShopTab::Buy => {
1634 if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
1635 if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
1636 return 1;
1637 }
1638 }
1639 99
1640 }
1641 ShopTab::Sell => catalog
1642 .buys
1643 .get(self.shop_menu_index)
1644 .map(|l| l.quantity)
1645 .unwrap_or(0),
1646 }
1647 }
1648
1649 pub fn shop_quantity_set_max(&mut self) {
1650 self.shop_quantity = self.shop_quantity_max();
1651 }
1652
1653 fn clamp_shop_quantity(&mut self) {
1654 let max = self.shop_quantity_max();
1655 if max == 0 {
1656 self.shop_quantity = 0;
1657 } else {
1658 self.shop_quantity = self.shop_quantity.max(1).min(max);
1659 }
1660 }
1661
1662 pub fn player_at_station_tag(&self, tag: &str) -> bool {
1663 let Some(id) = self.effective_inside_building() else {
1664 return false;
1665 };
1666 self.buildings
1667 .iter()
1668 .find(|b| b.id == id)
1669 .is_some_and(|b| b.tags.iter().any(|t| t == tag))
1670 }
1671
1672 pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
1674 if self.can_craft_blueprint(blueprint) {
1675 return None;
1676 }
1677 let mut missing = Vec::new();
1678 for input in &blueprint.inputs {
1679 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1680 if have < input.quantity {
1681 missing.push(format!(
1682 "{}×{} (have {have})",
1683 input.quantity, input.template_id
1684 ));
1685 }
1686 }
1687 for tool in &blueprint.required_tools {
1688 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1689 if have < 1 {
1690 missing.push(format!("tool: {}", tool.item));
1691 }
1692 }
1693 if let Some(station) = blueprint.station.as_deref() {
1694 if station != "hand" && !self.player_at_station_tag(station) {
1695 missing.push(format!("station: {station} (enter building)"));
1696 }
1697 }
1698 if missing.is_empty() {
1699 None
1700 } else {
1701 Some(missing.join(", "))
1702 }
1703 }
1704
1705 pub fn player_entity(&self) -> Option<&EntityState> {
1706 self.player
1707 .as_ref()
1708 .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
1709 }
1710
1711 pub fn apply_client_ui_prefs(&mut self) {
1713 let cfg = crate::client_config::ClientConfig::load();
1714 if let Some(hidden) = cfg.hud_log_hidden {
1715 self.hud_log_hidden = hidden;
1716 }
1717 if let Some(compact) = cfg.workers_menu_compact {
1718 self.workers_menu_compact = compact;
1719 }
1720 }
1721
1722 pub fn player_position(&self) -> (f32, f32) {
1723 let (x, y, _) = self.player_position_with_z();
1724 (x, y)
1725 }
1726
1727 pub fn player_position_with_z(&self) -> (f32, f32, f32) {
1728 if let Some(p) = self.player_entity() {
1729 (
1730 p.transform.position.x,
1731 p.transform.position.y,
1732 p.transform.position.z,
1733 )
1734 } else {
1735 (0.0, 0.0, 0.0)
1736 }
1737 }
1738
1739 pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
1740 let mut rows: Vec<(String, u32, String)> = self
1741 .inventory
1742 .iter()
1743 .filter(|(_, q)| **q > 0)
1744 .map(|(id, qty)| {
1745 let label = self
1746 .inventory_hints
1747 .get(id)
1748 .map(|h| h.display_name.clone())
1749 .unwrap_or_else(|| id.clone());
1750 (id.clone(), *qty, label)
1751 })
1752 .collect();
1753 rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
1754 rows
1755 }
1756
1757 pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
1758 self.inventory_hints
1759 .get(template_id)
1760 .map(|h| h.category.as_str())
1761 .filter(|c| !c.is_empty())
1762 }
1763
1764 pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
1765 stack
1766 .props
1767 .get("grants_item_status_effect")
1768 .map(|s| !s.is_empty())
1769 .unwrap_or(false)
1770 }
1771
1772 pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
1773 stack
1774 .props
1775 .get("grants_item_status_effect")
1776 .map(String::as_str)
1777 .filter(|s| !s.is_empty())
1778 }
1779
1780 pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
1781 stack
1782 .props
1783 .get("grants_item_status_mode")
1784 .map(String::as_str)
1785 .unwrap_or("on_hit")
1786 }
1787
1788 pub fn grant_target_options(
1790 &self,
1791 grant: &flatland_protocol::ItemStack,
1792 ) -> Vec<GrantTargetOption> {
1793 let mode = Self::grant_mode(grant);
1794 let grant_tags: Vec<&str> = grant
1795 .props
1796 .get("grants_item_status_tags")
1797 .map(|s| {
1798 s.split(',')
1799 .map(str::trim)
1800 .filter(|t| !t.is_empty())
1801 .collect()
1802 })
1803 .unwrap_or_default();
1804 let grant_id = grant.item_instance_id;
1805 let mut out = Vec::new();
1806 let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
1807 let Some(iid) = stack.item_instance_id else {
1808 return;
1809 };
1810 if Some(iid) == grant_id {
1811 return;
1812 }
1813 if stack.props.get("enchantable").map(String::as_str) == Some("0") {
1814 return;
1815 }
1816 if !grant_target_matches_mode(stack, mode) {
1817 return;
1818 }
1819 if !grant_tags_match(stack, &grant_tags) {
1820 return;
1821 }
1822 let name = stack
1823 .display_name
1824 .clone()
1825 .unwrap_or_else(|| stack.template_id.clone());
1826 let bindings = if stack.status_bindings.is_empty() {
1827 String::new()
1828 } else {
1829 format!(
1830 " · {}",
1831 stack
1832 .status_bindings
1833 .iter()
1834 .map(|b| b.effect_id.as_str())
1835 .collect::<Vec<_>>()
1836 .join(", ")
1837 )
1838 };
1839 out.push(GrantTargetOption {
1840 label: format!("{where_label}: {name}{bindings}"),
1841 target_instance_id: iid,
1842 });
1843 };
1844 fn walk(
1845 stacks: &[flatland_protocol::ItemStack],
1846 where_label: &str,
1847 push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
1848 ) {
1849 for s in stacks {
1850 push(s, where_label);
1851 if !s.contents.is_empty() {
1852 let nested = format!(
1853 "{where_label}/{}",
1854 s.display_name
1855 .as_deref()
1856 .unwrap_or(s.template_id.as_str())
1857 );
1858 walk(&s.contents, &nested, push);
1859 }
1860 }
1861 }
1862 walk(&self.inventory_stacks, "Bag", &mut push);
1863 for (slot, stack) in &self.worn {
1864 push(stack, body_slot_label(*slot));
1865 let nest = format!(
1866 "{}/{}",
1867 body_slot_label(*slot),
1868 stack
1869 .display_name
1870 .as_deref()
1871 .unwrap_or(stack.template_id.as_str())
1872 );
1873 walk(&stack.contents, &nest, &mut push);
1874 }
1875 out
1876 }
1877
1878 pub fn item_base_mass(&self, template_id: &str) -> f32 {
1879 self.inventory_hints
1880 .get(template_id)
1881 .and_then(|h| h.base_mass)
1882 .unwrap_or(0.5)
1883 }
1884
1885 pub fn item_base_volume(&self, template_id: &str) -> f32 {
1886 self.inventory_hints
1887 .get(template_id)
1888 .and_then(|h| h.base_volume)
1889 .unwrap_or(1.0)
1890 }
1891
1892 pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
1893 let unit = stack
1894 .base_mass
1895 .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
1896 unit * stack.quantity as f32
1897 }
1898
1899 fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
1900 let unit = stack.base_volume.unwrap_or(1.0);
1901 unit * stack.quantity as f32
1902 + stack
1903 .contents
1904 .iter()
1905 .map(Self::stack_tree_volume)
1906 .sum::<f32>()
1907 }
1908
1909 fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
1910 contents.iter().map(Self::stack_tree_volume).sum()
1911 }
1912
1913 fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
1914 self.inventory_hints
1915 .get(template_id)
1916 .and_then(|h| h.capacity_volume)
1917 .filter(|c| *c > 0.0)
1918 }
1919
1920 fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
1921 stack
1922 .capacity_volume
1923 .filter(|c| *c > 0.0)
1924 .or_else(|| self.template_capacity_volume(&stack.template_id))
1925 }
1926
1927 pub fn container_volume_label(&self, row: &InventoryRow) -> String {
1929 let Some((used, cap)) = self.container_volume_stats(row) else {
1930 return String::new();
1931 };
1932 let free = (cap - used).max(0.0);
1933 format!(" vol {used:.0}/{cap:.0} ({free:.0} free)")
1934 }
1935
1936 fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
1937 if row.is_chest_shell {
1938 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
1939 return None;
1940 };
1941 let chest = self
1942 .placed_containers
1943 .iter()
1944 .find(|c| c.id == *container_id)?;
1945 let cap = self
1946 .stack_capacity_volume(&row.stack)
1947 .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
1948 let used = if chest.accessible {
1949 Self::contents_used_volume(&chest.contents)
1950 } else {
1951 0.0
1952 };
1953 return Some((used, cap));
1954 }
1955
1956 let cap = self.stack_capacity_volume(&row.stack)?;
1957 let used = Self::contents_used_volume(&row.stack.contents);
1958 Some((used, cap))
1959 }
1960
1961 pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
1962 if row.is_chest_shell {
1963 return true;
1964 }
1965 if row.is_equip_shell {
1966 return self.inventory_item_category(&row.stack.template_id) == Some("container");
1967 }
1968 self.inventory_item_category(&row.stack.template_id) == Some("container")
1969 || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
1970 }
1971
1972 fn container_stack_for(
1973 &self,
1974 location: &flatland_protocol::InventoryLocation,
1975 parent_instance_id: Option<uuid::Uuid>,
1976 ) -> Option<flatland_protocol::ItemStack> {
1977 match location {
1978 flatland_protocol::InventoryLocation::Root => {
1979 let pid = parent_instance_id?;
1980 self.find_stack_by_instance(&self.inventory_stacks, pid)
1981 }
1982 flatland_protocol::InventoryLocation::Worn { slot } => {
1983 let worn = self.worn.get(slot)?;
1984 if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
1985 Some(worn.clone())
1986 } else {
1987 self.find_stack_by_instance(&worn.contents, parent_instance_id?)
1988 }
1989 }
1990 flatland_protocol::InventoryLocation::Placed { container_id } => {
1991 let chest = self
1992 .placed_containers
1993 .iter()
1994 .find(|c| c.id == *container_id)?;
1995 if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
1996 Some(flatland_protocol::ItemStack {
1997 template_id: chest.template_id.clone(),
1998 quantity: 1,
1999 item_instance_id: chest.item_instance_id,
2000 props: Default::default(),
2001 status_bindings: Vec::new(),
2002 contents: chest.contents.clone(),
2003 display_name: Some(chest.display_name.clone()),
2004 category: Some("container".into()),
2005 capacity_volume: self
2006 .inventory_hints
2007 .get(&chest.template_id)
2008 .and_then(|h| h.capacity_volume),
2009 worker_lodging_capacity: chest.worker_lodging_capacity,
2010 ..Default::default()
2011 })
2012 } else {
2013 self.find_stack_by_instance(&chest.contents, parent_instance_id?)
2014 }
2015 }
2016 flatland_protocol::InventoryLocation::Keychain => None,
2017 flatland_protocol::InventoryLocation::WhisperPouch => None,
2018 }
2019 }
2020
2021 fn find_stack_by_instance(
2022 &self,
2023 stacks: &[flatland_protocol::ItemStack],
2024 instance_id: uuid::Uuid,
2025 ) -> Option<flatland_protocol::ItemStack> {
2026 for stack in stacks {
2027 if stack.item_instance_id == Some(instance_id) {
2028 return Some(stack.clone());
2029 }
2030 if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
2031 return Some(found);
2032 }
2033 }
2034 None
2035 }
2036
2037 pub fn max_movable_to(
2039 &self,
2040 template_id: &str,
2041 stack_qty: u32,
2042 from: &flatland_protocol::InventoryLocation,
2043 to: &flatland_protocol::InventoryLocation,
2044 parent_instance_id: Option<uuid::Uuid>,
2045 ) -> u32 {
2046 let unit_vol = self.item_base_volume(template_id);
2047 let unit_mass = self.item_base_mass(template_id);
2048 let mut limit = stack_qty;
2049
2050 if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2051 let cap = parent
2052 .capacity_volume
2053 .or_else(|| {
2054 self.inventory_hints
2055 .get(&parent.template_id)
2056 .and_then(|h| h.capacity_volume)
2057 })
2058 .unwrap_or(0.0);
2059 if cap > 0.0 && unit_vol > 0.0 {
2060 let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2061 limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2062 }
2063 }
2064
2065 let to_person = matches!(
2066 to,
2067 flatland_protocol::InventoryLocation::Root
2068 | flatland_protocol::InventoryLocation::Worn { .. }
2069 );
2070 let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2071 if to_person && from_placed && unit_mass > 0.0 {
2072 let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2073 if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2074 limit = 0;
2075 } else {
2076 limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2077 }
2078 }
2079
2080 limit.max(0).min(stack_qty)
2081 }
2082
2083 pub fn move_picker_max_at_selection(&self) -> u32 {
2084 let Some(picker) = &self.move_picker else {
2085 return 1;
2086 };
2087 let Some(opt) = picker.options.get(self.move_picker_index) else {
2088 return picker.stack_quantity;
2089 };
2090 match &opt.kind {
2091 MoveOptionKind::Cancel
2092 | MoveOptionKind::Drop
2093 | MoveOptionKind::Use
2094 | MoveOptionKind::GrantApply
2095 | MoveOptionKind::PickupPlaced { .. } => picker.stack_quantity,
2096 MoveOptionKind::Move {
2097 location,
2098 parent_instance_id,
2099 } => self.max_movable_to(
2100 &picker.template_id,
2101 picker.stack_quantity,
2102 &picker.from,
2103 location,
2104 *parent_instance_id,
2105 ),
2106 }
2107 }
2108
2109 pub fn clamp_move_picker_quantity(&mut self) {
2110 let max = self.move_picker_max_at_selection();
2111 if let Some(picker) = &mut self.move_picker {
2112 if max == 0 {
2113 picker.quantity = 1;
2114 } else {
2115 picker.quantity = picker.quantity.clamp(1, max);
2116 }
2117 }
2118 }
2119
2120 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2121 let max = self.move_picker_max_at_selection().max(1);
2122 if let Some(picker) = &mut self.move_picker {
2123 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2124 picker.quantity = next as u32;
2125 }
2126 }
2127
2128 pub fn move_picker_set_quantity_max(&mut self) {
2129 let max = self.move_picker_max_at_selection();
2130 if let Some(picker) = &mut self.move_picker {
2131 picker.quantity = if max == 0 {
2132 1
2133 } else {
2134 max.min(picker.stack_quantity)
2135 };
2136 }
2137 }
2138
2139 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2140 if let Some(picker) = &mut self.destroy_picker {
2141 let max = picker.stack_quantity.max(1);
2142 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2143 picker.quantity = next as u32;
2144 }
2145 }
2146
2147 pub fn destroy_picker_set_quantity_max(&mut self) {
2148 if let Some(picker) = &mut self.destroy_picker {
2149 picker.quantity = picker.stack_quantity.max(1);
2150 }
2151 }
2152
2153 pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2154 let have = self.inventory.get(template_id).copied().unwrap_or(0);
2155 (have, have >= need)
2156 }
2157
2158 pub fn currency_display(&self) -> String {
2159 crate::currency::currency_line(&self.inventory)
2160 }
2161
2162 pub fn in_shallow_water(&self) -> bool {
2164 let (px, py) = self.player_position();
2165 self.terrain_at(px, py)
2166 .is_some_and(|k| k == TerrainKindView::ShallowWater)
2167 }
2168
2169 pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
2170 self.terrain_zone_at(x, y).map(|z| z.kind)
2171 }
2172
2173 pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
2175 self.terrain_zones
2176 .iter()
2177 .enumerate()
2178 .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
2179 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
2180 .map(|(_, z)| z)
2181 }
2182
2183 pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
2185 self.terrain_zone_at(x, y)
2186 .map(|z| z.elevation)
2187 .unwrap_or(0.0)
2188 }
2189
2190 pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
2192 const TOL: f32 = 0.35;
2193 let mut levels = vec![self.elevation_at(x, y)];
2194 for p in &self.z_platforms {
2195 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2196 levels.push(p.z);
2197 }
2198 }
2199 levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2200 levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
2201 levels
2202 }
2203
2204 pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
2205 const TOL: f32 = 0.35;
2206 self.walkable_levels_at(x, y)
2207 .iter()
2208 .any(|&l| (l - z).abs() <= TOL)
2209 }
2210
2211 pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
2212 let mut top = self.elevation_at(x, y);
2213 for p in &self.z_platforms {
2214 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2215 top = top.max(p.z);
2216 }
2217 }
2218 top
2219 }
2220
2221 pub fn effective_inside_building(&self) -> Option<String> {
2223 self.player_entity().and_then(|p| p.inside_building.clone())
2224 }
2225
2226 pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
2227 self.inventory_stacks = stacks.to_vec();
2228 self.inventory.clear();
2229 self.inventory_hints.clear();
2230 fn walk(
2231 stacks: &[flatland_protocol::ItemStack],
2232 inventory: &mut std::collections::HashMap<String, u32>,
2233 hints: &mut std::collections::HashMap<String, InventoryHint>,
2234 ) {
2235 for stack in stacks {
2236 *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
2237 if stack.display_name.is_some()
2238 || stack.category.is_some()
2239 || stack.base_mass.is_some()
2240 || stack.base_volume.is_some()
2241 {
2242 hints.insert(
2243 stack.template_id.clone(),
2244 InventoryHint {
2245 display_name: stack
2246 .display_name
2247 .clone()
2248 .unwrap_or_else(|| stack.template_id.clone()),
2249 category: stack.category.clone().unwrap_or_default(),
2250 base_mass: stack.base_mass,
2251 base_volume: stack.base_volume,
2252 capacity_volume: stack.capacity_volume,
2253 stackable: stack.stackable.unwrap_or(true),
2254 },
2255 );
2256 }
2257 walk(&stack.contents, inventory, hints);
2258 }
2259 }
2260 walk(stacks, &mut self.inventory, &mut self.inventory_hints);
2261 for item in self.worn.values() {
2263 walk(
2264 std::slice::from_ref(item),
2265 &mut self.inventory,
2266 &mut self.inventory_hints,
2267 );
2268 }
2269 }
2270
2271 pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
2274 let subtract_items =
2275 notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
2276 for stack in ¬ice.inventory_delta {
2277 if stack.quantity == 0 {
2278 continue;
2279 }
2280 if subtract_items {
2281 crate::currency::drain_template_stacks(
2282 &mut self.inventory_stacks,
2283 &stack.template_id,
2284 stack.quantity,
2285 );
2286 continue;
2287 }
2288 let stackable = self
2289 .inventory_hints
2290 .get(&stack.template_id)
2291 .map(|h| h.stackable)
2292 .or(stack.stackable)
2293 .unwrap_or(true);
2294 if stackable {
2295 if let Some(existing) = self
2296 .inventory_stacks
2297 .iter_mut()
2298 .find(|s| s.template_id == stack.template_id)
2299 {
2300 existing.quantity = existing.quantity.saturating_add(stack.quantity);
2301 if stack.display_name.is_some() {
2302 existing.display_name = stack.display_name.clone();
2303 }
2304 if stack.category.is_some() {
2305 existing.category = stack.category.clone();
2306 }
2307 continue;
2308 }
2309 }
2310 self.inventory_stacks.push(stack.clone());
2311 }
2312 if notice.coins_delta != 0 {
2313 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
2314 }
2315 if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
2316 let stacks = self.inventory_stacks.clone();
2317 self.sync_inventory_from_stacks(&stacks);
2318 }
2319 self.record_shop_trade_notice(notice);
2320 }
2321
2322 pub fn worn_rows(&self) -> Vec<InventoryRow> {
2327 let mut rows = Vec::new();
2328 for (slot, item) in &self.worn {
2329 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2330 rows.push(InventoryRow {
2331 depth: 0,
2332 stack: item.clone(),
2333 from: from.clone(),
2334 from_parent_instance_id: None,
2335 is_equip_shell: true,
2336 is_chest_shell: false,
2337 section: InventorySection::Worn,
2338 });
2339 for child in &item.contents {
2340 push_inventory_rows(
2341 &mut rows,
2342 1,
2343 child,
2344 &from,
2345 item.item_instance_id,
2346 InventorySection::Worn,
2347 );
2348 }
2349 }
2350 rows
2351 }
2352
2353 pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
2355 self.inventory_stacks
2356 .iter()
2357 .filter_map(|stack| {
2358 let item_instance_id = stack.item_instance_id?;
2359 let label = stack
2360 .display_name
2361 .clone()
2362 .unwrap_or_else(|| stack.template_id.clone());
2363 let label = if stack.quantity > 1 {
2364 format!("{label} ×{}", stack.quantity)
2365 } else {
2366 label
2367 };
2368 Some(WorkerGiveOption {
2369 item_instance_id,
2370 label,
2371 quantity: stack.quantity,
2372 template_id: stack.template_id.clone(),
2373 })
2374 })
2375 .collect()
2376 }
2377
2378 pub fn teachable_blueprint_options(
2380 &self,
2381 worker: &flatland_protocol::HiredWorkerView,
2382 ) -> Vec<WorkerTeachOption> {
2383 let copper = crate::currency::copper_from_counts(&self.inventory);
2384 let mut options: Vec<WorkerTeachOption> = self
2385 .blueprints
2386 .iter()
2387 .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
2388 .map(|bp| {
2389 let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
2390 let cost = bp.worker_train_copper;
2391 WorkerTeachOption {
2392 blueprint_id: bp.id.clone(),
2393 label: if bp.label.is_empty() {
2394 bp.id.clone()
2395 } else {
2396 bp.label.clone()
2397 },
2398 cost_copper: cost,
2399 min_level,
2400 worker_level: worker.level,
2401 can_afford: copper >= cost,
2402 level_ok: worker.level >= min_level,
2403 }
2404 })
2405 .collect();
2406 options.sort_by(|a, b| a.label.cmp(&b.label));
2407 options
2408 }
2409
2410 pub fn person_rows(&self) -> Vec<InventoryRow> {
2413 self.person_rows_filtered("")
2414 }
2415
2416 pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2417 let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
2418 roots.sort_by(|a, b| {
2419 let ca = a
2420 .category
2421 .as_deref()
2422 .or_else(|| self.inventory_item_category(&a.template_id))
2423 .unwrap_or("");
2424 let cb = b
2425 .category
2426 .as_deref()
2427 .or_else(|| self.inventory_item_category(&b.template_id))
2428 .unwrap_or("");
2429 let ga = inventory_category_group(ca).1;
2430 let gb = inventory_category_group(cb).1;
2431 ga.cmp(&gb).then_with(|| {
2432 let na = a
2433 .display_name
2434 .as_deref()
2435 .unwrap_or(a.template_id.as_str());
2436 let nb = b
2437 .display_name
2438 .as_deref()
2439 .unwrap_or(b.template_id.as_str());
2440 na.cmp(nb)
2441 })
2442 });
2443 let mut rows = Vec::new();
2444 for stack in roots {
2445 push_inventory_rows_filtered(
2446 &mut rows,
2447 0,
2448 stack,
2449 &flatland_protocol::InventoryLocation::Root,
2450 None,
2451 InventorySection::Person,
2452 filter,
2453 );
2454 }
2455 rows
2456 }
2457
2458 pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2459 if filter.is_empty() {
2460 return self.worn_rows();
2461 }
2462 let mut rows = Vec::new();
2463 for (slot, item) in &self.worn {
2464 if !stack_matches_filter(item, filter) {
2465 continue;
2466 }
2467 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2468 let self_hit = {
2469 let f = filter.to_ascii_lowercase();
2470 let name = item
2471 .display_name
2472 .as_deref()
2473 .unwrap_or("")
2474 .to_ascii_lowercase();
2475 let tid = item.template_id.to_ascii_lowercase();
2476 name.contains(&f) || tid.contains(&f)
2477 };
2478 rows.push(InventoryRow {
2479 depth: 0,
2480 stack: item.clone(),
2481 from: from.clone(),
2482 from_parent_instance_id: None,
2483 is_equip_shell: true,
2484 is_chest_shell: false,
2485 section: InventorySection::Worn,
2486 });
2487 for child in &item.contents {
2488 if self_hit || stack_matches_filter(child, filter) {
2489 push_inventory_rows_filtered(
2490 &mut rows,
2491 1,
2492 child,
2493 &from,
2494 item.item_instance_id,
2495 InventorySection::Worn,
2496 if self_hit { "" } else { filter },
2497 );
2498 }
2499 }
2500 }
2501 rows
2502 }
2503
2504 pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
2506 let mut rows = self.worn_rows();
2507 rows.extend(self.person_rows());
2508 rows.into_iter().map(|r| (r.depth, r.stack)).collect()
2509 }
2510
2511 pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
2515 let (px, py) = self.player_position();
2516 let mut list: Vec<NearbyContainer> = self
2517 .placed_containers
2518 .iter()
2519 .filter_map(|c| {
2520 let distance_m = (c.x - px).hypot(c.y - py);
2521 if distance_m > CONTAINER_RANGE_M {
2522 return None;
2523 }
2524 let mut rows = Vec::new();
2525 let from = flatland_protocol::InventoryLocation::Placed {
2526 container_id: c.id.clone(),
2527 };
2528 rows.push(InventoryRow {
2529 depth: 0,
2530 stack: flatland_protocol::ItemStack {
2531 template_id: c.template_id.clone(),
2532 quantity: 1,
2533 item_instance_id: c.item_instance_id,
2534 props: Default::default(),
2535 status_bindings: Vec::new(),
2536 contents: Vec::new(),
2537 display_name: Some(c.display_name.clone()),
2538 category: Some("container".into()),
2539 capacity_volume: c.capacity_volume,
2540 worker_lodging_capacity: c.worker_lodging_capacity,
2541 ..Default::default()
2542 },
2543 from: from.clone(),
2544 from_parent_instance_id: None,
2545 is_equip_shell: false,
2546 is_chest_shell: true,
2547 section: InventorySection::Nearby,
2548 });
2549 if c.accessible {
2550 for child in &c.contents {
2551 push_inventory_rows(
2552 &mut rows,
2553 1,
2554 child,
2555 &from,
2556 c.item_instance_id,
2557 InventorySection::Nearby,
2558 );
2559 }
2560 }
2561 Some(NearbyContainer {
2562 view: c.clone(),
2563 distance_m,
2564 rows,
2565 })
2566 })
2567 .collect();
2568 list.sort_by(|a, b| {
2569 a.distance_m
2570 .partial_cmp(&b.distance_m)
2571 .unwrap_or(std::cmp::Ordering::Equal)
2572 });
2573 list
2574 }
2575
2576 pub fn nearest_placed_container(
2578 &self,
2579 max_dist: f32,
2580 ) -> Option<flatland_protocol::PlacedContainerView> {
2581 let (px, py) = self.player_position();
2582 self.placed_containers
2583 .iter()
2584 .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
2585 .min_by(|a, b| {
2586 let da = (a.x - px).hypot(a.y - py);
2587 let db = (b.x - px).hypot(b.y - py);
2588 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
2589 })
2590 .cloned()
2591 }
2592
2593 pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
2596 let filter = self.inventory_filter.as_str();
2597 match self.inventory_tab {
2598 InventoryTab::OnPerson => {
2599 let mut rows = self.worn_rows_filtered(filter);
2600 rows.extend(self.person_rows_filtered(filter));
2601 rows
2602 }
2603 InventoryTab::Nearby => {
2604 let mut rows = Vec::new();
2605 for nc in self.nearby_containers() {
2606 if filter.is_empty() {
2607 rows.extend(nc.rows);
2608 continue;
2609 }
2610 let shell = nc.rows.first().cloned();
2611 let contents: Vec<_> = nc
2612 .rows
2613 .iter()
2614 .skip(1)
2615 .filter(|r| stack_matches_filter(&r.stack, filter))
2616 .cloned()
2617 .collect();
2618 let shell_hit = shell
2619 .as_ref()
2620 .map(|s| stack_matches_filter(&s.stack, filter))
2621 .unwrap_or(false);
2622 if shell_hit || !contents.is_empty() {
2623 if let Some(s) = shell {
2624 rows.push(s);
2625 }
2626 if shell_hit {
2627 rows.extend(nc.rows.into_iter().skip(1));
2628 } else {
2629 rows.extend(contents);
2630 }
2631 }
2632 }
2633 rows
2634 }
2635 }
2636 }
2637
2638 pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
2639 self.inventory_selectable_rows()
2640 .into_iter()
2641 .nth(self.inventory_menu_index)
2642 }
2643
2644 pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
2646 let cat = self
2647 .inventory_item_category(&row.stack.template_id)
2648 .unwrap_or("");
2649 let label = if cat == "key" {
2650 self.key_inventory_label(&row.stack)
2651 } else {
2652 row.stack
2653 .display_name
2654 .clone()
2655 .unwrap_or_else(|| row.stack.template_id.clone())
2656 };
2657 let hint: String = if row.is_equip_shell {
2658 " [worn — Enter to unequip]".into()
2659 } else if row.is_chest_shell {
2660 let (locked, lodging_note) = match &row.from {
2661 flatland_protocol::InventoryLocation::Placed { container_id } => {
2662 let locked = self
2663 .placed_containers
2664 .iter()
2665 .find(|c| c.id == *container_id)
2666 .map(|c| c.locked)
2667 .unwrap_or(false);
2668 let lodging_note = self
2669 .lodging_occupancy_label(container_id)
2670 .map(|who| format!(" [lodging: {who}]"))
2671 .unwrap_or_default();
2672 (locked, lodging_note)
2673 }
2674 _ => (false, String::new()),
2675 };
2676 if locked {
2677 format!(" [locked — Enter pick up · l unlock]{lodging_note}")
2678 } else {
2679 format!(" [Enter pick up · l lock]{lodging_note}")
2680 }
2681 } else if cat == "key" {
2682 self.key_inventory_hint(&row.stack)
2683 } else {
2684 match cat {
2685 "weapon" => " [weapon]".into(),
2686 "container" => " [bag/chest/belt]".into(),
2687 "lodging" => " [worker lodging]".into(),
2688 "armor" => " [armor]".into(),
2689 _ => String::new(),
2690 }
2691 };
2692 let qty = if row.stack.quantity > 1 {
2693 format!(" ×{}", row.stack.quantity)
2694 } else {
2695 String::new()
2696 };
2697 let bindings = format_status_bindings_suffix(
2698 &row.stack.status_bindings,
2699 self.tick,
2700 DEFAULT_TICK_HZ,
2701 );
2702 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
2703 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
2704 let mode = Self::grant_mode(&row.stack);
2705 format!(" [grant {effect} · {mode} — e apply]")
2706 } else {
2707 String::new()
2708 };
2709 let mass = self.stack_mass(&row.stack);
2710 let mass_kg = (mass >= 0.05).then_some(mass);
2711 let mass_str = mass_kg
2712 .map(|m| format!(" {m:.1} kg"))
2713 .unwrap_or_default();
2714 let volume = self.container_volume_stats(row);
2715 let vol_str = self.container_volume_label(row);
2716
2717 let mut title = label.clone();
2718 if let Some(id) = row.stack.item_instance_id {
2719 let hex: String = id
2720 .as_simple()
2721 .to_string()
2722 .chars()
2723 .filter(|c| c.is_ascii_hexdigit())
2724 .collect();
2725 if hex.len() >= 4 {
2726 title.push_str(&format!(" #{}", &hex[hex.len() - 4..]));
2727 }
2728 }
2729 title.push_str(&qty);
2730 if row.is_equip_shell {
2731 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
2732 title.push_str(&format!(" ({})", body_slot_label(slot)));
2733 }
2734 }
2735
2736 InventoryRowView {
2737 depth: row.depth,
2738 text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
2739 title: format!("{title}{grant_hint}{bindings}"),
2740 mass_kg,
2741 volume,
2742 }
2743 }
2744
2745 fn push_browser_item(
2746 &self,
2747 lines: &mut Vec<InventoryBrowserLine>,
2748 row: &InventoryRow,
2749 global_idx: &mut usize,
2750 target: usize,
2751 highlight: bool,
2752 ) {
2753 let view = self.format_inventory_row(row);
2754 lines.push(InventoryBrowserLine::Item {
2755 selectable_index: *global_idx,
2756 selected: highlight && *global_idx == target,
2757 depth: view.depth,
2758 text: view.text,
2759 title: view.title,
2760 mass_kg: view.mass_kg,
2761 volume: view.volume,
2762 });
2763 *global_idx += 1;
2764 }
2765
2766 pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
2769 let mut lines = Vec::new();
2770 let target = self.inventory_menu_index;
2771 let highlight = !self.show_move_picker && !self.show_grant_picker;
2772 let filter = self.inventory_filter.as_str();
2773 let mut global_idx = 0usize;
2774
2775 match self.inventory_tab {
2776 InventoryTab::OnPerson => {
2777 lines.push(InventoryBrowserLine::Section("— Worn —".into()));
2778 let worn = self.worn_rows_filtered(filter);
2779 if worn.is_empty() {
2780 lines.push(InventoryBrowserLine::Hint(
2781 " (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
2782 ));
2783 } else {
2784 for row in &worn {
2785 if row.is_equip_shell {
2786 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
2787 lines.push(InventoryBrowserLine::SlotLabel(format!(
2788 " {}:",
2789 body_slot_label(slot)
2790 )));
2791 }
2792 }
2793 self.push_browser_item(
2794 &mut lines,
2795 row,
2796 &mut global_idx,
2797 target,
2798 highlight,
2799 );
2800 }
2801 }
2802
2803 lines.push(InventoryBrowserLine::Blank);
2804 lines.push(InventoryBrowserLine::Section(
2805 "— On you (loose, not worn) —".into(),
2806 ));
2807 let person = self.person_rows_filtered(filter);
2808 if person.is_empty() {
2809 lines.push(InventoryBrowserLine::Hint(" (empty)".into()));
2810 } else {
2811 let mut last_group: Option<&'static str> = None;
2812 for row in &person {
2813 if row.depth == 0 {
2814 let cat = row
2815 .stack
2816 .category
2817 .as_deref()
2818 .or_else(|| self.inventory_item_category(&row.stack.template_id))
2819 .unwrap_or("");
2820 let (group, _) = inventory_category_group(cat);
2821 if last_group != Some(group) {
2822 lines.push(InventoryBrowserLine::SlotLabel(format!(
2823 " {group}"
2824 )));
2825 last_group = Some(group);
2826 }
2827 }
2828 self.push_browser_item(
2829 &mut lines,
2830 row,
2831 &mut global_idx,
2832 target,
2833 highlight,
2834 );
2835 }
2836 }
2837 }
2838 InventoryTab::Nearby => {
2839 let nearby = self.nearby_containers();
2840 if nearby.is_empty() {
2841 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
2842 lines.push(InventoryBrowserLine::Hint(
2843 " (none within reach — walk up to a chest)".into(),
2844 ));
2845 lines.push(InventoryBrowserLine::Hint(
2846 " Select an on-person item, then m / Enter → move into chest.".into(),
2847 ));
2848 } else {
2849 let mut any_visible = false;
2850 for nc in &nearby {
2851 let shell = nc.rows.first();
2852 let contents: Vec<&InventoryRow> = if filter.is_empty() {
2853 nc.rows.iter().skip(1).collect()
2854 } else {
2855 let shell_hit = shell
2856 .map(|s| {
2857 let f = filter.to_ascii_lowercase();
2858 let name = s
2859 .stack
2860 .display_name
2861 .as_deref()
2862 .unwrap_or("")
2863 .to_ascii_lowercase();
2864 let tid = s.stack.template_id.to_ascii_lowercase();
2865 name.contains(&f) || tid.contains(&f)
2866 })
2867 .unwrap_or(false);
2868 if shell_hit {
2869 nc.rows.iter().skip(1).collect()
2870 } else {
2871 nc.rows
2872 .iter()
2873 .skip(1)
2874 .filter(|r| stack_matches_filter(&r.stack, filter))
2875 .collect()
2876 }
2877 };
2878 let shell_visible = filter.is_empty()
2879 || shell
2880 .map(|s| stack_matches_filter(&s.stack, filter))
2881 .unwrap_or(false)
2882 || !contents.is_empty();
2883 if !shell_visible && shell.is_some() {
2884 continue;
2885 }
2886 any_visible = true;
2887 lines.push(InventoryBrowserLine::Blank);
2888 let lock_note = if nc.view.locked && nc.view.accessible {
2889 " unlocked with your key"
2890 } else if nc.view.locked {
2891 " locked"
2892 } else {
2893 ""
2894 };
2895 lines.push(InventoryBrowserLine::Section(format!(
2896 "— {} ({:.0}m away){lock_note} —",
2897 nc.view.display_name, nc.distance_m
2898 )));
2899 if !nc.view.accessible {
2900 lines.push(InventoryBrowserLine::Hint(
2901 " locked — need the matching key (l to try)".into(),
2902 ));
2903 } else if nc.rows.is_empty() {
2904 lines.push(InventoryBrowserLine::Hint(
2905 " (empty — switch to On person, select an item, m to move in)"
2906 .into(),
2907 ));
2908 } else if let Some(shell_row) = shell {
2909 self.push_browser_item(
2910 &mut lines,
2911 shell_row,
2912 &mut global_idx,
2913 target,
2914 highlight,
2915 );
2916 for row in contents {
2917 self.push_browser_item(
2918 &mut lines,
2919 row,
2920 &mut global_idx,
2921 target,
2922 highlight,
2923 );
2924 }
2925 }
2926 }
2927 if !any_visible {
2928 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
2929 lines.push(InventoryBrowserLine::Hint(
2930 " (no matching items — clear filter with Esc)".into(),
2931 ));
2932 }
2933 }
2934 }
2935 }
2936 lines
2937 }
2938
2939 pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
2941 let mut opts = Vec::new();
2942 opts.push(MoveOption {
2943 label: "On your person (loose)".into(),
2944 kind: MoveOptionKind::PickupPlaced {
2945 container_id: container_id.to_string(),
2946 nest_location: flatland_protocol::InventoryLocation::Root,
2947 nest_parent_instance_id: None,
2948 },
2949 });
2950 for (slot, item) in &self.worn {
2951 if item.category.as_deref() != Some("container") {
2952 continue;
2953 }
2954 if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
2955 continue;
2956 }
2957 let Some(parent_id) = item.item_instance_id else {
2958 continue;
2959 };
2960 let shell_name = item
2961 .display_name
2962 .clone()
2963 .unwrap_or_else(|| item.template_id.clone());
2964 opts.push(MoveOption {
2965 label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
2966 kind: MoveOptionKind::PickupPlaced {
2967 container_id: container_id.to_string(),
2968 nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
2969 nest_parent_instance_id: Some(parent_id),
2970 },
2971 });
2972 Self::append_chest_pickup_nested(
2974 &mut opts,
2975 container_id,
2976 flatland_protocol::InventoryLocation::Worn { slot: *slot },
2977 item,
2978 &format!("in {shell_name}"),
2979 );
2980 }
2981 opts.push(MoveOption {
2982 label: "Cancel".into(),
2983 kind: MoveOptionKind::Cancel,
2984 });
2985 opts
2986 }
2987
2988 fn append_chest_pickup_nested(
2989 opts: &mut Vec<MoveOption>,
2990 container_id: &str,
2991 location: flatland_protocol::InventoryLocation,
2992 parent: &flatland_protocol::ItemStack,
2993 context: &str,
2994 ) {
2995 for child in &parent.contents {
2996 if child.category.as_deref() != Some("container") {
2997 continue;
2998 }
2999 if !Self::is_volume_container_stack(child) {
3000 continue;
3001 }
3002 if child.world_placeable == Some(true) {
3004 continue;
3005 }
3006 let Some(child_id) = child.item_instance_id else {
3007 continue;
3008 };
3009 let name = child
3010 .display_name
3011 .clone()
3012 .unwrap_or_else(|| child.template_id.clone());
3013 opts.push(MoveOption {
3014 label: format!("{name} ({context})"),
3015 kind: MoveOptionKind::PickupPlaced {
3016 container_id: container_id.to_string(),
3017 nest_location: location.clone(),
3018 nest_parent_instance_id: Some(child_id),
3019 },
3020 });
3021 Self::append_chest_pickup_nested(
3022 opts,
3023 container_id,
3024 location.clone(),
3025 child,
3026 &format!("in {name}"),
3027 );
3028 }
3029 }
3030
3031 pub fn move_destinations_for(
3033 &self,
3034 from: &flatland_protocol::InventoryLocation,
3035 from_parent_instance_id: Option<uuid::Uuid>,
3036 moving_instance_id: Option<uuid::Uuid>,
3037 moving_template_id: &str,
3038 ) -> Vec<MoveOption> {
3039 let mut opts = Vec::new();
3040 if *from != flatland_protocol::InventoryLocation::Root {
3041 opts.push(MoveOption {
3042 label: "On your person (loose)".into(),
3043 kind: MoveOptionKind::Move {
3044 location: flatland_protocol::InventoryLocation::Root,
3045 parent_instance_id: None,
3046 },
3047 });
3048 }
3049 for (slot, item) in &self.worn {
3050 if item.category.as_deref() != Some("container") {
3051 continue;
3052 }
3053 let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3054 let shell_name = item
3055 .display_name
3056 .clone()
3057 .unwrap_or_else(|| item.template_id.clone());
3058
3059 if *slot != BodySlot::Waist
3061 && item.item_instance_id != moving_instance_id
3062 && Self::is_volume_container_stack(item)
3063 {
3064 Self::push_move_destination(
3065 &mut opts,
3066 format!("{shell_name} (worn {})", body_slot_label(*slot)),
3067 location.clone(),
3068 item.item_instance_id,
3069 from,
3070 from_parent_instance_id,
3071 );
3072 }
3073
3074 if *slot == BodySlot::Waist
3076 && Self::attaches_to_belt_loop(moving_template_id)
3077 && item.item_instance_id != moving_instance_id
3078 {
3079 Self::push_move_destination(
3080 &mut opts,
3081 format!("{shell_name} (belt loop)"),
3082 location.clone(),
3083 item.item_instance_id,
3084 from,
3085 from_parent_instance_id,
3086 );
3087 }
3088
3089 let context = if *slot == BodySlot::Waist {
3090 format!("on {shell_name}")
3091 } else {
3092 format!("in {shell_name}")
3093 };
3094 Self::append_nested_container_destinations(
3095 &mut opts,
3096 location,
3097 item,
3098 &context,
3099 from,
3100 from_parent_instance_id,
3101 moving_instance_id,
3102 );
3103 }
3104 for nc in self.nearby_containers() {
3105 if !nc.view.accessible {
3106 continue;
3107 }
3108 let location = flatland_protocol::InventoryLocation::Placed {
3109 container_id: nc.view.id.clone(),
3110 };
3111 Self::push_move_destination(
3112 &mut opts,
3113 format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
3114 location,
3115 nc.view.item_instance_id,
3116 from,
3117 from_parent_instance_id,
3118 );
3119 }
3120 let allow_drop = moving_instance_id
3121 .and_then(|id| self.stack_for_instance(id))
3122 .map(|stack| !self.key_drop_blocked(&stack))
3123 .unwrap_or(moving_template_id != KEY_TEMPLATE);
3124 if allow_drop {
3125 opts.push(MoveOption {
3126 label: "Drop on the ground".into(),
3127 kind: MoveOptionKind::Drop,
3128 });
3129 }
3130 opts.push(MoveOption {
3131 label: "Cancel".into(),
3132 kind: MoveOptionKind::Cancel,
3133 });
3134 opts
3135 }
3136
3137 fn is_same_container_dest(
3138 dest_location: &flatland_protocol::InventoryLocation,
3139 dest_parent: Option<uuid::Uuid>,
3140 from: &flatland_protocol::InventoryLocation,
3141 from_parent: Option<uuid::Uuid>,
3142 ) -> bool {
3143 dest_location == from && dest_parent == from_parent
3144 }
3145
3146 fn push_move_destination(
3147 opts: &mut Vec<MoveOption>,
3148 label: String,
3149 location: flatland_protocol::InventoryLocation,
3150 parent_instance_id: Option<uuid::Uuid>,
3151 from: &flatland_protocol::InventoryLocation,
3152 from_parent_instance_id: Option<uuid::Uuid>,
3153 ) {
3154 if Self::is_same_container_dest(
3155 &location,
3156 parent_instance_id,
3157 from,
3158 from_parent_instance_id,
3159 ) {
3160 return;
3161 }
3162 opts.push(MoveOption {
3163 label,
3164 kind: MoveOptionKind::Move {
3165 location,
3166 parent_instance_id,
3167 },
3168 });
3169 }
3170
3171 fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
3172 stack.capacity_volume.is_some_and(|c| c > 0.0)
3173 }
3174
3175 fn attaches_to_belt_loop(template_id: &str) -> bool {
3176 matches!(template_id, "leather_pouch" | "dimensional_pouch")
3177 }
3178
3179 fn append_nested_container_destinations(
3180 opts: &mut Vec<MoveOption>,
3181 location: flatland_protocol::InventoryLocation,
3182 container: &flatland_protocol::ItemStack,
3183 context: &str,
3184 from: &flatland_protocol::InventoryLocation,
3185 from_parent_instance_id: Option<uuid::Uuid>,
3186 moving_instance_id: Option<uuid::Uuid>,
3187 ) {
3188 for child in &container.contents {
3189 if Self::is_volume_container_stack(child)
3190 && child.item_instance_id != moving_instance_id
3191 {
3192 let name = child
3193 .display_name
3194 .clone()
3195 .unwrap_or_else(|| child.template_id.clone());
3196 Self::push_move_destination(
3197 opts,
3198 format!("{name} ({context})"),
3199 location.clone(),
3200 child.item_instance_id,
3201 from,
3202 from_parent_instance_id,
3203 );
3204 }
3205 let nested_context = format!(
3206 "in {}",
3207 child.display_name.as_deref().unwrap_or(&child.template_id)
3208 );
3209 Self::append_nested_container_destinations(
3210 opts,
3211 location.clone(),
3212 child,
3213 &nested_context,
3214 from,
3215 from_parent_instance_id,
3216 moving_instance_id,
3217 );
3218 }
3219 }
3220
3221 fn clamp_inventory_indices(&mut self) {
3222 let n = self.inventory_selectable_rows().len();
3223 self.inventory_menu_index = if n == 0 {
3224 0
3225 } else {
3226 self.inventory_menu_index.min(n - 1)
3227 };
3228 if let Some(picker) = &self.move_picker {
3229 let pn = picker.options.len();
3230 self.move_picker_index = if pn == 0 {
3231 0
3232 } else {
3233 self.move_picker_index.min(pn - 1)
3234 };
3235 }
3236 }
3237
3238 fn sync_interior_map_context(&mut self) {
3240 if self.effective_inside_building().is_none() {
3241 self.interior_map = None;
3242 }
3243 }
3244
3245 fn apply_snapshot_fields(
3246 &mut self,
3247 snapshot: &flatland_protocol::Snapshot,
3248 entity_id: EntityId,
3249 ) {
3250 self.tick = snapshot.tick;
3251 self.chunk_rev = snapshot.chunk_rev;
3252 self.content_rev = snapshot.content_rev;
3253 self.publish_rev = snapshot.publish_rev;
3254 self.resource_nodes = snapshot.resource_nodes.clone();
3255 self.ground_drops = snapshot.ground_drops.clone();
3256 self.placed_containers = snapshot.placed_containers.clone();
3257 self.world_width_m = snapshot.world_width_m;
3258 self.world_height_m = snapshot.world_height_m;
3259 self.world_clock = snapshot.world_clock;
3260 self.terrain_zones = snapshot.terrain_zones.clone();
3261 self.z_platforms = snapshot.z_platforms.clone();
3262 self.z_transitions = snapshot.z_transitions.clone();
3263 self.buildings = snapshot.buildings.clone();
3264 self.doors = snapshot.doors.clone();
3265 self.interior_map = snapshot.interior_map.clone();
3266 self.npcs = snapshot.npcs.clone();
3267 self.blueprints = snapshot.blueprints.clone();
3268 self.sync_inventory_from_stacks(&snapshot.inventory);
3269 self.player = snapshot
3270 .entities
3271 .iter()
3272 .find(|e| e.id == entity_id)
3273 .cloned();
3274 self.entities = snapshot.entities.clone();
3275 self.quest_log = snapshot.quest_log.clone();
3276 self.apply_hired_workers(snapshot.hired_workers.clone());
3277 self.interactables = snapshot.interactables.clone();
3278 self.ledger = snapshot.ledger.clone();
3279 self.career = snapshot.career.clone();
3280 self.combat_fx = snapshot.combat_fx.clone();
3281 self.sync_interior_map_context();
3282 self.refresh_whisper_range();
3283 }
3284
3285 fn refresh_inventory_ui(&mut self) {
3289 if let Some(picker) = &self.move_picker {
3290 let instance_id = picker.item_instance_id;
3291 let still_exists = self
3292 .inventory_selectable_rows()
3293 .iter()
3294 .any(|r| r.stack.item_instance_id == Some(instance_id));
3295 if !still_exists {
3296 self.move_picker = None;
3297 self.show_move_picker = false;
3298 }
3299 }
3300 if let Some(picker) = &self.destroy_picker {
3301 let instance_id = picker.item_instance_id;
3302 let still_exists = self
3303 .inventory_selectable_rows()
3304 .iter()
3305 .any(|r| r.stack.item_instance_id == Some(instance_id));
3306 if !still_exists {
3307 self.destroy_picker = None;
3308 self.show_destroy_picker = false;
3309 self.destroy_confirm_pending = false;
3310 }
3311 }
3312 self.clamp_inventory_indices();
3313 }
3314
3315 fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
3321 let selected_id = self
3322 .hired_workers
3323 .get(self.workers_menu_index)
3324 .map(|w| w.instance_id.clone());
3325 workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
3326 let now = Instant::now();
3327 for w in &workers {
3328 let prev_err = self
3329 .hired_workers
3330 .iter()
3331 .find(|p| p.instance_id == w.instance_id)
3332 .and_then(|p| p.last_error.as_deref());
3333 let new_err = w.last_error.as_deref();
3334 if new_err != prev_err {
3335 if let Some(err) = new_err {
3336 if !worker_error_is_transient(err) {
3337 self.push_log(format!("Worker {}: {err}", w.label));
3338 }
3339 }
3340 }
3341 }
3342 let mut next_display = BTreeMap::new();
3343 let mut next_errors = BTreeMap::new();
3344 for w in &workers {
3345 let mut sticky = self
3346 .worker_step_display
3347 .remove(&w.instance_id)
3348 .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
3349 sticky.observe(&w.step_label, now);
3350 next_display.insert(w.instance_id.clone(), sticky);
3351
3352 let mut err_sticky = self
3353 .worker_error_display
3354 .remove(&w.instance_id)
3355 .unwrap_or_default();
3356 err_sticky.observe(w.last_error.as_deref(), now);
3357 if err_sticky.shown(now).is_some() {
3358 next_errors.insert(w.instance_id.clone(), err_sticky);
3359 }
3360 }
3361 self.worker_step_display = next_display;
3362 self.worker_error_display = next_errors;
3363 self.hired_workers = workers;
3364 if let Some(id) = selected_id {
3365 if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
3366 self.workers_menu_index = idx;
3367 return;
3368 }
3369 }
3370 if self.workers_menu_index >= self.hired_workers.len() {
3371 self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
3372 }
3373 }
3374
3375 pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
3377 self.worker_step_display
3378 .get(worker_instance_id)
3379 .map(|s| s.shown.as_str())
3380 .or_else(|| {
3381 self.hired_workers
3382 .iter()
3383 .find(|w| w.instance_id == worker_instance_id)
3384 .map(|w| w.step_label.as_str())
3385 })
3386 .unwrap_or("")
3387 }
3388
3389 pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
3391 let now = Instant::now();
3392 self.worker_error_display
3393 .get(worker_instance_id)
3394 .and_then(|s| s.shown(now))
3395 .or_else(|| {
3396 self.hired_workers
3397 .iter()
3398 .find(|w| w.instance_id == worker_instance_id)
3399 .and_then(|w| w.last_error.as_deref())
3400 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
3401 })
3402 .filter(|e| !worker_error_is_hud_noise(e))
3403 }
3404
3405 fn apply_combat_hud(&mut self, combat: &CombatHud) {
3406 self.in_combat = combat.in_combat;
3407 self.auto_attack = combat.auto_attack;
3408 self.combat_has_los = combat.has_los;
3409 self.attack_cd_ticks = combat.attack_cd_ticks;
3410 self.gcd_ticks = combat.gcd_ticks;
3411 self.weapon_ability_id = combat.ability_id.clone();
3412 self.mainhand_template_id = combat.mainhand_template_id.clone();
3413 self.mainhand_label = combat.mainhand_label.clone();
3414 self.offhand_template_id = combat.offhand_template_id.clone();
3415 self.offhand_label = combat.offhand_label.clone();
3416 self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
3417 1
3418 } else {
3419 combat.mainhand_hand_slots
3420 };
3421 self.defense = combat.defense.clone();
3422 self.worn = combat.worn.iter().cloned().collect();
3423 self.carry_mass = combat.carry_mass;
3424 self.carry_mass_max = combat.carry_mass_max;
3425 self.encumbrance = combat.encumbrance;
3426 self.cast_progress = combat.cast.clone();
3427 self.ability_cooldowns = combat.ability_cooldowns.clone();
3428 self.blocking_active = combat.blocking_active;
3429 self.max_target_slots = combat.max_target_slots.max(1);
3430 self.combat_slots = combat.slots.clone();
3431 self.rotation_presets = combat.rotation_presets.clone();
3432 self.known_abilities = combat.known_abilities.clone();
3433 self.hotbar = combat.hotbar.clone();
3434 self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
3435 self.keychain_stacks = combat.keychain.clone();
3436 self.whisper_pouch_stacks = combat.whisper_pouch.clone();
3437 self.combat_target_detail = combat.target.clone();
3438 self.statuses = combat.statuses.clone();
3439 self.combat_target = combat.target_entity_id;
3440 if combat.progression_xp_base > 0.0 {
3441 self.progression_curve = Some(flatland_protocol::ProgressionCurve {
3442 baseline_display: combat.progression_baseline,
3443 xp_base: combat.progression_xp_base,
3444 xp_growth: combat.progression_xp_growth,
3445 });
3446 }
3447 if let Some(xp) = &combat.progression_xp {
3448 if let Some(player) = &mut self.player {
3449 player.progression_xp = Some(xp.clone());
3450 if let Some(attrs) = combat.attributes {
3451 player.attributes = Some(attrs);
3452 }
3453 if let Some(skills) = &combat.skills {
3454 player.skills = Some(skills.clone());
3455 }
3456 }
3457 }
3458 if let Some(label) = &combat.target_label {
3459 self.combat_target_label = Some(label.clone());
3460 } else if let Some(id) = combat.target_entity_id {
3461 self.combat_target_label = self
3462 .entities
3463 .iter()
3464 .find(|e| e.id == id)
3465 .map(|e| e.label.clone())
3466 .or_else(|| self.combat_target_label.clone());
3467 }
3468 self.refresh_inventory_ui();
3469 }
3470
3471 pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
3473 self.combat_slots
3474 .iter()
3475 .find(|s| s.slot_index == slot)
3476 .and_then(|s| s.target_entity_id)
3477 .or_else(|| if slot == 1 { self.combat_target } else { None })
3478 }
3479
3480 pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
3483 if !(1..=9).contains(&slot_1_to_9) {
3484 return None;
3485 }
3486 self.hotbar
3487 .get((slot_1_to_9 - 1) as usize)
3488 .and_then(|a| a.as_deref())
3489 .filter(|id| !id.is_empty())
3490 }
3491
3492 pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
3494 let binding = self.hotbar_ability(slot_1_to_9)?;
3495 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
3496 let name = self
3497 .inventory_hints
3498 .get(template_id)
3499 .map(|h| h.display_name.as_str())
3500 .unwrap_or(template_id);
3501 let qty = self.inventory.get(template_id).copied().unwrap_or(0);
3502 Some(format!("{name}×{qty}"))
3503 } else {
3504 Some(binding.to_string())
3505 }
3506 }
3507
3508 pub fn loadout_ability_choices(&self) -> Vec<String> {
3510 let mut out = self.known_abilities.clone();
3511 let weapon = self.weapon_ability_id.trim();
3512 if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
3513 out.push(weapon.to_string());
3514 }
3515 out
3516 }
3517
3518 pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
3520 let mut out = Vec::new();
3521 for ability in self.loadout_ability_choices() {
3522 let meta = if ability == self.weapon_ability_id {
3523 Some("weapon".into())
3524 } else {
3525 None
3526 };
3527 out.push(LoadoutHotbarChoice {
3528 binding: ability.clone(),
3529 label: ability,
3530 meta,
3531 });
3532 }
3533 let mut consumables: Vec<(String, String, u32)> = Vec::new();
3534 for stack in &self.inventory_stacks {
3535 if Self::stack_is_item_grant(stack) {
3536 continue;
3537 }
3538 if self.inventory_item_category(&stack.template_id) != Some("consumable") {
3539 continue;
3540 }
3541 let qty = stack.quantity.max(1);
3542 if let Some((_, _, existing)) = consumables
3543 .iter_mut()
3544 .find(|(id, _, _)| id == &stack.template_id)
3545 {
3546 *existing = existing.saturating_add(qty);
3547 } else {
3548 let label = stack
3549 .display_name
3550 .clone()
3551 .or_else(|| {
3552 self.inventory_hints
3553 .get(&stack.template_id)
3554 .map(|h| h.display_name.clone())
3555 })
3556 .unwrap_or_else(|| stack.template_id.clone());
3557 consumables.push((stack.template_id.clone(), label, qty));
3558 }
3559 }
3560 consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
3561 for (template_id, label, qty) in consumables {
3562 out.push(LoadoutHotbarChoice {
3563 binding: flatland_protocol::hotbar_consumable_binding(&template_id),
3564 label: format!("{label} ×{qty}"),
3565 meta: Some("use".into()),
3566 });
3567 }
3568 out
3569 }
3570
3571 pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
3573 self.combat_candidates()
3574 }
3575
3576 pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
3578 let (px, py) = self.player_position();
3579 let dist = |id: EntityId| {
3580 self.entities
3581 .iter()
3582 .find(|e| e.id == id)
3583 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
3584 .unwrap_or(f32::MAX)
3585 };
3586
3587 let mut allies = Vec::new();
3588 if let Some(me) = self.player.as_ref() {
3590 let alive = me
3591 .vitals
3592 .as_ref()
3593 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
3594 .unwrap_or(true);
3595 if alive {
3596 allies.push((self.entity_id, "Yourself".into()));
3597 }
3598 }
3599 for entity in &self.entities {
3600 if entity.id == self.entity_id {
3601 continue;
3602 }
3603 if entity.vitals.is_some() {
3604 let alive = entity
3605 .vitals
3606 .as_ref()
3607 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
3608 .unwrap_or(true);
3609 if alive {
3610 allies.push((entity.id, entity.label.clone()));
3611 }
3612 }
3613 }
3614 allies.sort_by(|(a, _), (b, _)| {
3615 if *a == self.entity_id {
3616 return std::cmp::Ordering::Less;
3617 }
3618 if *b == self.entity_id {
3619 return std::cmp::Ordering::Greater;
3620 }
3621 dist(*a)
3622 .partial_cmp(&dist(*b))
3623 .unwrap_or(std::cmp::Ordering::Equal)
3624 });
3625
3626 let mut monsters = self.combat_candidates();
3627 monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
3628 allies.into_iter().chain(monsters).collect()
3629 }
3630
3631 fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
3632 match slot_index {
3633 2 => self.t2_candidates(),
3634 _ => self.t1_candidates(),
3635 }
3636 }
3637
3638 pub fn pick_combat_target_at(
3640 &self,
3641 wx: f32,
3642 wy: f32,
3643 slot_index: u8,
3644 radius_m: f32,
3645 ) -> Option<(EntityId, String)> {
3646 let mut best: Option<(f32, EntityId, String)> = None;
3647 for (id, label) in self.candidates_for_slot(slot_index) {
3648 let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
3649 if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
3651 let d = distance(wx, wy, npc.x, npc.y);
3652 if d <= radius_m {
3653 best = match best {
3654 Some((bd, _, _)) if bd <= d => best,
3655 _ => Some((d, id, label)),
3656 };
3657 }
3658 }
3659 continue;
3660 };
3661 let d = distance(
3662 wx,
3663 wy,
3664 entity.transform.position.x,
3665 entity.transform.position.y,
3666 );
3667 if d <= radius_m {
3668 best = match best {
3669 Some((bd, _, _)) if bd <= d => best,
3670 _ => Some((d, id, label)),
3671 };
3672 }
3673 }
3674 best.map(|(_, id, label)| (id, label))
3675 }
3676
3677 pub(crate) fn restore_from_welcome(
3679 &mut self,
3680 session_id: SessionId,
3681 entity_id: EntityId,
3682 snapshot: &flatland_protocol::Snapshot,
3683 ) {
3684 self.clear_harvest_state();
3685 self.disconnect_reason = None;
3686 self.show_stats = false;
3687 self.show_craft_menu = false;
3688 self.show_shop_menu = false;
3689 self.shop_catalog = None;
3690 self.show_inventory_menu = false;
3691 self.session_id = session_id;
3692 self.entity_id = entity_id;
3693 self.connected = true;
3694 self.apply_snapshot_fields(snapshot, entity_id);
3695 if let Some(combat) = &snapshot.combat {
3696 self.apply_combat_hud(combat);
3697 let stacks = self.inventory_stacks.clone();
3698 self.sync_inventory_from_stacks(&stacks);
3699 }
3700 }
3701
3702 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
3703 self.tick = delta.tick;
3704 self.world_clock = delta.world_clock;
3705
3706 if delta.entities.is_empty() {
3708 self.combat_fx = delta.combat_fx.clone();
3709 if let Some(combat) = &delta.combat {
3710 self.apply_combat_hud(combat);
3711 let stacks = self.inventory_stacks.clone();
3712 self.sync_inventory_from_stacks(&stacks);
3713 }
3714 self.refresh_whisper_range();
3716 return;
3717 }
3718 if !delta.buildings.is_empty() {
3719 self.buildings = delta.buildings.clone();
3720 }
3721 if !delta.blueprints.is_empty() {
3722 self.blueprints = delta.blueprints.clone();
3723 }
3724 self.sync_inventory_from_stacks(&delta.inventory);
3725
3726 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
3727 self.player = Some(updated.clone());
3728 }
3729 self.entities = delta.entities.clone();
3730 if self.player.is_none() {
3731 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
3732 }
3733
3734 self.sync_interior_map_context();
3735
3736 if !delta.resource_nodes.is_empty() {
3740 self.resource_nodes = delta.resource_nodes.clone();
3741 } else if delta.interior_map.is_some()
3742 || self.effective_inside_building().is_some()
3743 {
3744 self.resource_nodes = delta.resource_nodes.clone();
3745 }
3746 if self
3747 .player
3748 .as_ref()
3749 .is_none_or(|p| p.inside_building.is_none())
3750 {
3751 self.ground_drops = delta.ground_drops.clone();
3752 self.placed_containers = delta.placed_containers.clone();
3753 }
3754 if !delta.doors.is_empty() {
3755 self.doors = delta.doors.clone();
3756 }
3757 if self.effective_inside_building().is_some() {
3758 if let Some(map) = &delta.interior_map {
3759 self.interior_map = Some(map.clone());
3760 }
3761 } else {
3762 self.interior_map = None;
3763 }
3764 self.npcs = delta.npcs.clone();
3766 if !delta.quest_log.is_empty() {
3767 self.quest_log = delta.quest_log.clone();
3768 }
3769 self.apply_hired_workers(delta.hired_workers.clone());
3770 if !delta.interactables.is_empty() {
3771 self.interactables = delta.interactables.clone();
3772 }
3773 if delta.ledger.is_some() {
3774 self.ledger = delta.ledger.clone();
3775 }
3776 if delta.career.is_some() {
3777 self.career = delta.career.clone();
3778 }
3779 self.combat_fx = delta.combat_fx.clone();
3780 if let Some(combat) = &delta.combat {
3781 self.apply_combat_hud(combat);
3782 let stacks = self.inventory_stacks.clone();
3783 self.sync_inventory_from_stacks(&stacks);
3784 } else {
3785 self.refresh_inventory_ui();
3786 }
3787 self.refresh_whisper_range();
3788 }
3789
3790 fn refresh_whisper_range(&mut self) {
3793 let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
3794 return;
3795 };
3796 let (px, py) = self.player_position();
3797 let in_range = self.entities.iter().any(|e| {
3798 e.id == peer
3799 && distance(
3800 px,
3801 py,
3802 e.transform.position.x,
3803 e.transform.position.y,
3804 ) <= INTERACTION_RADIUS_M
3805 });
3806 if !in_range {
3807 self.social_chat.cancel_whisper_out_of_range();
3808 }
3809 }
3810
3811 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
3813 let (px, py) = self.player_position();
3814 let mut out = Vec::new();
3815 for npc in &self.npcs {
3816 let Some(eid) = npc.entity_id else {
3817 continue;
3818 };
3819 let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
3820 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
3821 if alive && has_hp {
3822 out.push((eid, npc.label.clone()));
3823 }
3824 }
3825 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
3826 let dist = |id: EntityId| {
3827 self.entities
3828 .iter()
3829 .find(|e| e.id == id)
3830 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
3831 .unwrap_or(f32::MAX)
3832 };
3833 dist(*a_id)
3834 .partial_cmp(&dist(*b_id))
3835 .unwrap_or(std::cmp::Ordering::Equal)
3836 .then_with(|| a_label.cmp(b_label))
3837 .then_with(|| a_id.cmp(b_id))
3838 });
3839 out
3840 }
3841
3842 pub fn refresh_combat_target_label(&mut self) {
3843 let Some(id) = self.combat_target else {
3844 return;
3845 };
3846 if let Some((_, label)) = self
3847 .combat_candidates()
3848 .into_iter()
3849 .find(|(eid, _)| *eid == id)
3850 {
3851 self.combat_target_label = Some(label);
3852 } else if let Some(label) = self
3853 .entities
3854 .iter()
3855 .find(|e| e.id == id)
3856 .map(|e| e.label.clone())
3857 {
3858 self.combat_target_label = Some(label);
3859 }
3860 }
3861
3862 pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
3863 self.quest_log
3864 .iter()
3865 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
3866 .collect()
3867 }
3868
3869 pub fn has_worker_lodging(&self) -> bool {
3871 self.free_worker_lodging_slots() > 0
3872 }
3873
3874 pub fn free_worker_lodging_slots(&self) -> i64 {
3876 let slots: u32 = self
3877 .placed_containers
3878 .iter()
3879 .filter(|c| match (self.character_id, c.owner_character_id) {
3880 (Some(me), Some(owner)) => me == owner,
3881 (Some(_), None) => false,
3882 (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
3883 })
3884 .map(|c| c.worker_lodging_capacity.unwrap_or(0))
3885 .sum();
3886 let used = self.hired_workers.len() as u32;
3887 slots as i64 - used as i64
3888 }
3889
3890 pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
3892 let mut names: Vec<String> = self
3893 .hired_workers
3894 .iter()
3895 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
3896 .map(|w| w.label.clone())
3897 .collect();
3898 names.sort();
3899 names
3900 }
3901
3902 pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
3904 let is_lodging = self
3905 .placed_containers
3906 .iter()
3907 .find(|c| c.id == container_id)
3908 .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
3909 if !is_lodging {
3910 return None;
3911 }
3912 let names = self.lodging_occupant_labels(container_id);
3913 Some(if names.is_empty() {
3914 "vacant".into()
3915 } else {
3916 names.join(", ")
3917 })
3918 }
3919
3920 pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
3921 self.quest_log
3922 .iter()
3923 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
3924 .or_else(|| {
3925 self.quest_log
3926 .iter()
3927 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
3928 })
3929 }
3930
3931 pub fn nearest_interact_target(&self) -> Option<String> {
3933 let (px, py) = self.player_position();
3934 let inside = self.effective_inside_building();
3935
3936 #[derive(Clone, Copy, PartialEq, Eq)]
3937 enum Kind {
3938 Player,
3939 Npc,
3940 QuestBoard,
3941 ExitDoor,
3942 EnterDoor,
3943 Well,
3944 Water,
3945 }
3946
3947 fn kind_priority(kind: Kind) -> u8 {
3948 match kind {
3949 Kind::Player => 0,
3950 Kind::Npc => 0,
3951 Kind::QuestBoard => 1,
3952 Kind::ExitDoor => 2,
3953 Kind::EnterDoor => 3,
3954 Kind::Well => 4,
3955 Kind::Water => 5,
3956 }
3957 }
3958
3959 let mut best: Option<(f32, Kind, String)> = None;
3960
3961 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
3962 if dist > max {
3963 return;
3964 }
3965 let replace = match best {
3966 None => true,
3967 Some((bd, _bk, _)) if dist < bd - 0.05 => true,
3968 Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
3969 kind_priority(kind) < kind_priority(bk)
3970 }
3971 _ => false,
3972 };
3973 if replace {
3974 best = Some((dist, kind, id));
3975 }
3976 };
3977
3978 for npc in &self.npcs {
3979 consider(
3980 distance(px, py, npc.x, npc.y),
3981 INTERACTION_RADIUS_M,
3982 Kind::Npc,
3983 npc.id.clone(),
3984 );
3985 }
3986
3987 for entity in &self.entities {
3988 if entity.id == self.entity_id || entity.vitals.is_none() || entity.label.trim().is_empty()
3989 {
3990 continue;
3991 }
3992 consider(
3993 distance(
3994 px,
3995 py,
3996 entity.transform.position.x,
3997 entity.transform.position.y,
3998 ),
3999 INTERACTION_RADIUS_M,
4000 Kind::Player,
4001 entity.id.to_string(),
4002 );
4003 }
4004
4005 for door in &self.doors {
4006 if let Some(ref bid) = inside {
4007 if door.building_id != *bid {
4008 continue;
4009 }
4010 let is_exit = door.portal.is_some();
4011 let max = if is_exit {
4012 INTERACTION_RADIUS_M
4013 } else {
4014 DOOR_INTERACTION_RADIUS_M
4015 };
4016 let kind = if is_exit {
4017 Kind::ExitDoor
4018 } else {
4019 Kind::EnterDoor
4020 };
4021 consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
4022 continue;
4023 }
4024 consider(
4025 distance(px, py, door.x, door.y),
4026 DOOR_INTERACTION_RADIUS_M,
4027 Kind::EnterDoor,
4028 door.id.clone(),
4029 );
4030 }
4031
4032 if inside.is_none() {
4033 for inter in &self.interactables {
4034 if inter.kind == "quest_board" {
4035 consider(
4036 distance(px, py, inter.x, inter.y),
4037 QUEST_BOARD_INTERACTION_RADIUS_M,
4038 Kind::QuestBoard,
4039 inter.id.clone(),
4040 );
4041 }
4042 }
4043 for building in &self.buildings {
4044 if !building.tags.iter().any(|t| t == "well") {
4045 continue;
4046 }
4047 consider(
4048 distance(px, py, building.x, building.y),
4049 INTERACTION_RADIUS_M,
4050 Kind::Well,
4051 building.id.clone(),
4052 );
4053 }
4054 if self.in_shallow_water() {
4055 consider(
4056 0.0,
4057 INTERACTION_RADIUS_M,
4058 Kind::Water,
4059 "water_source".into(),
4060 );
4061 }
4062 }
4063
4064 best.map(|(_, _, id)| id)
4065 }
4066
4067 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
4069 if self.effective_inside_building().is_some() {
4070 return None;
4071 }
4072 let (px, py) = self.player_position();
4073 self.interactables
4074 .iter()
4075 .filter(|i| i.kind == "quest_board")
4076 .map(|i| {
4077 let label = if i.label.is_empty() {
4078 "Quest board".to_string()
4079 } else {
4080 i.label.clone()
4081 };
4082 (label, distance(px, py, i.x, i.y))
4083 })
4084 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
4085 }
4086
4087 pub fn template_display_name(&self, template_id: &str) -> String {
4089 self.inventory_hints
4090 .get(template_id)
4091 .map(|h| h.display_name.clone())
4092 .filter(|n| !n.is_empty())
4093 .unwrap_or_else(|| humanize_template_id(template_id))
4094 }
4095
4096 pub fn placed_container_public_label(
4098 &self,
4099 c: &flatland_protocol::PlacedContainerView,
4100 ) -> String {
4101 let is_owner = match (self.character_id, c.owner_character_id) {
4102 (Some(me), Some(owner)) => me == owner,
4103 _ => false,
4104 };
4105 if is_owner {
4106 c.display_name.clone()
4107 } else {
4108 self.template_display_name(&c.template_id)
4109 }
4110 }
4111
4112 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
4114 let mut out = Vec::new();
4115 for stack in &self.inventory_stacks {
4116 if stack.template_id == KEY_TEMPLATE {
4117 out.push(KeychainEntry {
4118 stack: stack.clone(),
4119 stowed: false,
4120 });
4121 }
4122 }
4123 for stack in &self.keychain_stacks {
4124 if stack.template_id == KEY_TEMPLATE {
4125 out.push(KeychainEntry {
4126 stack: stack.clone(),
4127 stowed: true,
4128 });
4129 }
4130 }
4131 out
4132 }
4133
4134 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
4136 if stack.template_id != KEY_TEMPLATE {
4137 return None;
4138 }
4139 if let Some(name) = stack
4140 .props
4141 .get(PROP_OPENS_CONTAINER_NAME)
4142 .filter(|n| !n.is_empty())
4143 {
4144 return Some(name.clone());
4145 }
4146 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
4147 self.container_name_for_lock_id(opens)
4148 }
4149
4150 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
4152 if stack.template_id == KEY_TEMPLATE {
4153 self.template_display_name(KEY_TEMPLATE)
4154 } else {
4155 stack
4156 .display_name
4157 .clone()
4158 .unwrap_or_else(|| stack.template_id.clone())
4159 }
4160 }
4161
4162 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
4164 if stack.template_id != KEY_TEMPLATE {
4165 return String::new();
4166 }
4167 match self.key_pair_chest_label(stack) {
4168 Some(chest) if self.key_drop_blocked(stack) => {
4169 format!(" [key for {chest} — can't drop while locked]")
4170 }
4171 Some(chest) => format!(" [key for {chest}]"),
4172 None => " [key — unpaired]".into(),
4173 }
4174 }
4175
4176 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
4178 for c in &self.placed_containers {
4179 if c.lock_id.as_deref() == Some(lock) {
4180 return Some(c.display_name.clone());
4181 }
4182 }
4183 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
4184 self.worn
4185 .values()
4186 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
4187 })
4188 }
4189
4190 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
4192 if stack.template_id != KEY_TEMPLATE {
4193 return false;
4194 }
4195 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
4196 return false;
4197 };
4198 for c in &self.placed_containers {
4199 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
4200 return true;
4201 }
4202 }
4203 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
4204 return true;
4205 }
4206 self.worn
4207 .values()
4208 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
4209 }
4210
4211 fn container_name_in_stacks(
4212 stacks: &[flatland_protocol::ItemStack],
4213 lock: &str,
4214 ) -> Option<String> {
4215 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
4216 for s in stacks {
4217 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
4218 return Some(GameState::stack_container_label(s));
4219 }
4220 if let Some(name) = walk(&s.contents, lock) {
4221 return Some(name);
4222 }
4223 }
4224 None
4225 }
4226 walk(stacks, lock)
4227 }
4228
4229 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
4230 stack
4231 .props
4232 .get(PROP_CUSTOM_NAME)
4233 .cloned()
4234 .or_else(|| stack.display_name.clone())
4235 .unwrap_or_else(|| stack.template_id.clone())
4236 }
4237
4238 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
4239 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
4240 for s in stacks {
4241 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
4242 return true;
4243 }
4244 if walk(&s.contents, lock) {
4245 return true;
4246 }
4247 }
4248 false
4249 }
4250 walk(stacks, lock)
4251 }
4252
4253 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
4254 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
4255 return Some(stack.clone());
4256 }
4257 for worn in self.worn.values() {
4258 if worn.item_instance_id == Some(instance_id) {
4259 return Some(worn.clone());
4260 }
4261 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
4262 return Some(stack.clone());
4263 }
4264 }
4265 None
4266 }
4267
4268 pub fn location_context_lines(&self) -> Vec<ContextLine> {
4270 let (px, py) = self.player_position();
4271 let inside = self.effective_inside_building();
4272 let mut lines = Vec::new();
4273
4274 if let Some(kind) = self.terrain_at(px, py) {
4275 lines.push(ContextLine {
4276 on_top: true,
4277 text: format!("Terrain: {}", terrain_kind_label(kind)),
4278 });
4279 }
4280
4281 if let Some(id) = inside.as_ref() {
4282 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
4283 lines.push(ContextLine {
4284 on_top: true,
4285 text: format!("Inside: {}", b.label),
4286 });
4287 }
4288 }
4289
4290 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
4291
4292 for node in &self.resource_nodes {
4293 let dist = distance(px, py, node.x, node.y);
4294 if dist > NEARBY_SCAN_M {
4295 continue;
4296 }
4297 let on_top = dist <= ON_TOP_RADIUS_M;
4298 let state = match node.state {
4299 flatland_protocol::ResourceNodeState::Available => " — f harvest",
4300 flatland_protocol::ResourceNodeState::Harvesting => " (being harvested)",
4301 flatland_protocol::ResourceNodeState::Cooldown => " (depleted)",
4302 };
4303 let prefix = if on_top { "On" } else { "Near" };
4304 nearby.push((
4305 dist,
4306 ContextLine {
4307 on_top,
4308 text: format!("{prefix}: {} ({dist:.1}m){state}", node.label),
4309 },
4310 ));
4311 }
4312
4313 for drop in &self.ground_drops {
4314 let dist = distance(px, py, drop.x, drop.y);
4315 if dist > INTERACTION_RADIUS_M {
4316 continue;
4317 }
4318 let on_top = dist <= ON_TOP_RADIUS_M;
4319 let name = self.template_display_name(&drop.template_id);
4320 let prefix = if on_top { "On" } else { "Near" };
4321 let qty = if drop.quantity > 1 {
4322 format!(" ×{}", drop.quantity)
4323 } else {
4324 String::new()
4325 };
4326 nearby.push((
4327 dist,
4328 ContextLine {
4329 on_top,
4330 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
4331 },
4332 ));
4333 }
4334
4335 for c in &self.placed_containers {
4336 let dist = distance(px, py, c.x, c.y);
4337 if dist > CONTAINER_RANGE_M {
4338 continue;
4339 }
4340 let on_top = dist <= ON_TOP_RADIUS_M;
4341 let name = self.placed_container_public_label(c);
4342 let lock = if c.locked { " [locked]" } else { "" };
4343 let prefix = if on_top { "On" } else { "Near" };
4344 nearby.push((
4345 dist,
4346 ContextLine {
4347 on_top,
4348 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
4349 },
4350 ));
4351 }
4352
4353 for npc in &self.npcs {
4354 let dist = distance(px, py, npc.x, npc.y);
4355 if dist > NEARBY_SCAN_M {
4356 continue;
4357 }
4358 let on_top = dist <= ON_TOP_RADIUS_M;
4359 let prefix = if on_top { "On" } else { "Near" };
4360 nearby.push((
4361 dist,
4362 ContextLine {
4363 on_top,
4364 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
4365 },
4366 ));
4367 }
4368
4369 for door in &self.doors {
4370 let dist = distance(px, py, door.x, door.y);
4371 if dist > DOOR_INTERACTION_RADIUS_M {
4372 continue;
4373 }
4374 let building = self
4375 .buildings
4376 .iter()
4377 .find(|b| b.id == door.building_id)
4378 .map(|b| b.label.as_str())
4379 .unwrap_or(door.building_id.as_str());
4380 let action = if inside.is_some() && door.portal.is_some() {
4381 "exit"
4382 } else {
4383 "enter"
4384 };
4385 nearby.push((
4386 dist,
4387 ContextLine {
4388 on_top: dist <= ON_TOP_RADIUS_M,
4389 text: format!("{building} door ({dist:.1}m) — f {action}"),
4390 },
4391 ));
4392 }
4393
4394 if inside.is_none() {
4395 for inter in &self.interactables {
4396 if inter.kind != "quest_board" {
4397 continue;
4398 }
4399 let dist = distance(px, py, inter.x, inter.y);
4400 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
4401 continue;
4402 }
4403 let on_top = dist <= ON_TOP_RADIUS_M;
4404 let prefix = if on_top { "On" } else { "Near" };
4405 let label = if inter.label.is_empty() {
4406 "Quest board".to_string()
4407 } else {
4408 inter.label.clone()
4409 };
4410 nearby.push((
4411 dist,
4412 ContextLine {
4413 on_top,
4414 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
4415 },
4416 ));
4417 }
4418 }
4419
4420 if self.in_shallow_water() {
4421 let already = self
4422 .terrain_at(px, py)
4423 .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
4424 if !already {
4425 nearby.push((
4426 0.0,
4427 ContextLine {
4428 on_top: true,
4429 text: "Shallow water — f fill bottle".into(),
4430 },
4431 ));
4432 } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
4433 line.text.push_str(" — f fill bottle");
4434 }
4435 }
4436
4437 for entity in &self.entities {
4438 if entity.id == self.entity_id {
4439 continue;
4440 }
4441 let dist = distance(
4442 px,
4443 py,
4444 entity.transform.position.x,
4445 entity.transform.position.y,
4446 );
4447 if dist > NEARBY_SCAN_M {
4448 continue;
4449 }
4450 let label = if entity.label.is_empty() {
4451 format!("entity {}", entity.id)
4452 } else {
4453 entity.label.clone()
4454 };
4455 nearby.push((
4456 dist,
4457 ContextLine {
4458 on_top: dist <= ON_TOP_RADIUS_M,
4459 text: format!("Near: {label} ({dist:.1}m)"),
4460 },
4461 ));
4462 }
4463
4464 nearby.sort_by(|a, b| {
4465 a.0.partial_cmp(&b.0)
4466 .unwrap_or(std::cmp::Ordering::Equal)
4467 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
4468 });
4469 lines.extend(nearby.into_iter().map(|(_, l)| l));
4470
4471 if lines.is_empty() {
4472 lines.push(ContextLine {
4473 on_top: false,
4474 text: "(nothing notable nearby)".into(),
4475 });
4476 }
4477
4478 lines
4479 }
4480}
4481
4482#[derive(Debug, Clone)]
4484pub struct ContextLine {
4485 pub on_top: bool,
4486 pub text: String,
4487}
4488
4489const ON_TOP_RADIUS_M: f32 = 0.65;
4490const NEARBY_SCAN_M: f32 = 5.0;
4491
4492fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
4493 use flatland_protocol::TerrainKindView;
4494 match kind {
4495 TerrainKindView::Grass => "Grass",
4496 TerrainKindView::Dirt => "Dirt",
4497 TerrainKindView::Tilled => "Tilled",
4498 TerrainKindView::Desert => "Desert",
4499 TerrainKindView::Hill => "Hills",
4500 TerrainKindView::Bog => "Bog",
4501 TerrainKindView::ShallowWater => "Shallow water",
4502 TerrainKindView::DeepWater => "Deep water",
4503 TerrainKindView::Trail => "Trail",
4504 TerrainKindView::Road => "Road",
4505 TerrainKindView::Rock => "Rock",
4506 }
4507}
4508
4509fn humanize_template_id(template_id: &str) -> String {
4510 template_id
4511 .split('_')
4512 .map(|word| {
4513 let mut chars = word.chars();
4514 match chars.next() {
4515 None => String::new(),
4516 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
4517 }
4518 })
4519 .collect::<Vec<_>>()
4520 .join(" ")
4521}
4522
4523const HARVEST_RANGE_M: f32 = 1.5;
4525
4526pub struct GameClient<S: PlayConnection> {
4527 session: S,
4528 seq: Seq,
4529 pub state: GameState,
4530 last_move_forward: f32,
4531 last_move_strafe: f32,
4532}
4533
4534impl<S: PlayConnection> GameClient<S> {
4535 pub fn new(session: S) -> Self {
4536 let session_id = session.session_id();
4537 let entity_id = session.entity_id();
4538 let mut client = Self {
4539 session,
4540 seq: 0,
4541 last_move_forward: 0.0,
4542 last_move_strafe: 0.0,
4543 state: GameState {
4544 session_id,
4545 entity_id,
4546 character_id: None,
4547 tick: 0,
4548 chunk_rev: 0,
4549 content_rev: 0,
4550 publish_rev: 0,
4551 entities: Vec::new(),
4552 player: None,
4553 resource_nodes: Vec::new(),
4554 ground_drops: Vec::new(),
4555 placed_containers: Vec::new(),
4556 buildings: Vec::new(),
4557 doors: Vec::new(),
4558 interior_map: None,
4559 npcs: Vec::new(),
4560 blueprints: Vec::new(),
4561 world_width_m: 0.0,
4562 world_height_m: 0.0,
4563 terrain_zones: Vec::new(),
4564 z_platforms: Vec::new(),
4565 z_transitions: Vec::new(),
4566 world_clock: flatland_protocol::WorldClock::default(),
4567 inventory: std::collections::HashMap::new(),
4568 inventory_hints: std::collections::HashMap::new(),
4569 logs: VecDeque::new(),
4570 intents_sent: 0,
4571 ticks_received: 0,
4572 connected: false,
4573 disconnect_reason: None,
4574 show_stats: false,
4575 hud_log_hidden: false,
4576 show_equip_menu: false,
4577 equip_menu_index: 0,
4578 show_craft_menu: false,
4579 craft_menu_index: 0,
4580 craft_batch_quantity: 1,
4581 show_shop_menu: false,
4582 shop_catalog: None,
4583 bank_panel: None,
4584 bank_menu_index: 0,
4585 bank_ui_mode: BankUiMode::Menu,
4586 storage_panel: None,
4587 storage_menu_index: 0,
4588 storage_ui_mode: StorageUiMode::Menu,
4589 shop_tab: ShopTab::default(),
4590 shop_menu_index: 0,
4591 shop_quantity: 1,
4592 shop_trade_log: VecDeque::new(),
4593 show_npc_verb_menu: false,
4594 npc_verb_target: None,
4595 npc_verb_index: 0,
4596 player_verbs: crate::social::PlayerVerbState::default(),
4597 social_chat: crate::social::SocialChatState::default(),
4598 trade_ui: crate::social::TradeUiState::default(),
4599 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
4600 show_npc_chat: false,
4601 npc_chat: None,
4602 show_inventory_menu: false,
4603 inventory_menu_index: 0,
4604 inventory_tab: InventoryTab::OnPerson,
4605 inventory_filter: String::new(),
4606 inventory_filter_focused: false,
4607 show_move_picker: false,
4608 show_rename_prompt: false,
4609 show_worker_rename: false,
4610 rename_buffer: String::new(),
4611 move_picker_index: 0,
4612 move_picker: None,
4613 show_grant_picker: false,
4614 grant_picker_index: 0,
4615 grant_picker: None,
4616 show_destroy_picker: false,
4617 destroy_confirm_pending: false,
4618 destroy_picker: None,
4619 combat_target: None,
4620 combat_target_label: None,
4621 combat_fx: Vec::new(),
4622 in_combat: false,
4623 auto_attack: true,
4624 combat_has_los: false,
4625 attack_cd_ticks: 0,
4626 gcd_ticks: 0,
4627 weapon_ability_id: "unarmed".into(),
4628 mainhand_template_id: None,
4629 mainhand_label: None,
4630 offhand_template_id: None,
4631 offhand_label: None,
4632 mainhand_hand_slots: 1,
4633 defense: None,
4634 worn: BTreeMap::new(),
4635 carry_mass: 0.0,
4636 carry_mass_max: 0.0,
4637 encumbrance: flatland_protocol::EncumbranceState::Light,
4638 inventory_stacks: Vec::new(),
4639 keychain_stacks: Vec::new(),
4640 whisper_pouch_stacks: Vec::new(),
4641 combat_target_detail: None,
4642 statuses: Vec::new(),
4643 cast_progress: None,
4644 ability_cooldowns: Vec::new(),
4645 blocking_active: false,
4646 max_target_slots: 1,
4647 combat_slots: Vec::new(),
4648 rotation_presets: Vec::new(),
4649 known_abilities: Vec::new(),
4650 hotbar: vec![None; 9],
4651 max_abilities_per_rotation: 0,
4652 show_loadout_menu: false,
4653 show_keychain_menu: false,
4654 keychain_menu_index: 0,
4655 show_rotation_editor: false,
4656 loadout_menu_index: 0,
4657 loadout_hotbar_slot: 1,
4658 loadout_ability_index: 0,
4659 loadout_focus_presets: false,
4660 rotation_editor: RotationEditorState::default(),
4661 harvest_in_progress: false,
4662 harvest_started_at: None,
4663 pending_craft_ack: None,
4664 pending_worker_job_ack: None,
4665 quest_log: Vec::new(),
4666 interactables: Vec::new(),
4667 ledger: None,
4668 career: None,
4669 character_sheet_tab: CharacterSheetTab::Character,
4670 ledger_period: LedgerPeriod::Day,
4671 show_quest_offer: false,
4672 pending_quest_offer: None,
4673 show_quest_menu: false,
4674 quest_menu_index: 0,
4675 quest_withdraw_confirm: false,
4676 hired_workers: Vec::new(),
4677 show_workers_menu: false,
4678 workers_menu_index: 0,
4679 workers_menu_compact: false,
4680 worker_step_display: BTreeMap::new(),
4681 worker_error_display: BTreeMap::new(),
4682 show_worker_give_picker: false,
4683 worker_give_picker_index: 0,
4684 worker_give_picker: None,
4685 show_worker_give_target_picker: false,
4686 worker_give_target_picker_index: 0,
4687 worker_give_target_picker: None,
4688 show_worker_take_picker: false,
4689 worker_take_picker_index: 0,
4690 worker_take_picker: None,
4691 show_worker_teach_picker: false,
4692 worker_teach_picker_index: 0,
4693 worker_teach_picker: None,
4694 worker_route_editor: None,
4695 progression_curve: None,
4696 },
4697 };
4698 client.state.apply_client_ui_prefs();
4699 client
4700 }
4701
4702 pub fn entity_id(&self) -> EntityId {
4703 self.state.entity_id
4704 }
4705
4706 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
4707 if self.state.connected {
4708 return Ok(());
4709 }
4710
4711 loop {
4712 match self.session.next_event().await {
4713 Some(SessionEvent::Welcome {
4714 session_id,
4715 entity_id,
4716 snapshot,
4717 }) => {
4718 self.state
4719 .restore_from_welcome(session_id, entity_id, &snapshot);
4720 self.state.apply_client_ui_prefs();
4721 self.state.push_log(format!(
4722 "Connected — session {session_id}, entity {entity_id}"
4723 ));
4724 return Ok(());
4725 }
4726 Some(SessionEvent::Disconnected { .. }) => {
4727 anyhow::bail!("disconnected before welcome");
4728 }
4729 Some(_) => continue,
4730 None => anyhow::bail!("session closed before welcome"),
4731 }
4732 }
4733 }
4734
4735 pub fn drain_events(&mut self) {
4737 while let Some(event) = self.session.try_next_event() {
4738 if self.handle_event_sync(event).is_err() {
4739 break;
4740 }
4741 }
4742 }
4743
4744 pub async fn next_event(&mut self) -> Option<SessionEvent> {
4746 self.session.next_event().await
4747 }
4748
4749 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
4750 self.handle_event_sync(event)
4751 }
4752
4753 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
4754 match event {
4755 SessionEvent::Welcome {
4756 session_id,
4757 entity_id,
4758 snapshot,
4759 } => {
4760 let resumed = self.state.connected;
4761 self.state
4762 .restore_from_welcome(session_id, entity_id, &snapshot);
4763 if resumed {
4764 self.state.push_log(format!(
4765 "Session restored — session {session_id}, entity {entity_id}"
4766 ));
4767 }
4768 }
4769 SessionEvent::ContentUpdated { snapshot } => {
4770 self.state
4771 .apply_snapshot_fields(&snapshot, self.state.entity_id);
4772 self.state.push_log(format!(
4773 "World updated (content rev {})",
4774 snapshot.content_rev
4775 ));
4776 }
4777 SessionEvent::Tick(delta) => {
4778 self.state.apply_tick_fields(&delta, self.state.entity_id);
4779 self.state.ticks_received += 1;
4780 }
4781 SessionEvent::IntentAck {
4782 entity_id,
4783 seq,
4784 tick,
4785 } => {
4786 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
4787 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
4788 if *craft_seq == seq {
4789 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
4790 if batches > 1 {
4791 self.state.push_log(format!("Crafting {label} ×{batches}…"));
4792 } else {
4793 self.state.push_log(format!("Crafting {label}…"));
4794 }
4795 }
4796 }
4797 if self
4798 .state
4799 .pending_worker_job_ack
4800 .as_ref()
4801 .is_some_and(|p| p.seq == seq)
4802 {
4803 let pending = self.state.pending_worker_job_ack.take().unwrap();
4804 if pending.idle {
4805 self.state.push_log(format!(
4806 "Route cleared for {} — worker idle",
4807 pending.worker_label
4808 ));
4809 } else {
4810 self.state.push_log(format!(
4811 "Route saved for {} — {} stop(s), job loop active",
4812 pending.worker_label, pending.stop_count
4813 ));
4814 }
4815 if self
4816 .state
4817 .worker_route_editor
4818 .as_ref()
4819 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
4820 {
4821 self.close_worker_route_editor();
4822 }
4823 }
4824 }
4825 SessionEvent::Chat(msg) => {
4826 let label = match msg.channel {
4827 flatland_protocol::ChatChannel::Nearby => "nearby",
4828 flatland_protocol::ChatChannel::Direct => "speak",
4829 flatland_protocol::ChatChannel::Whisper => "whisper",
4830 flatland_protocol::ChatChannel::WhisperStone => "stone",
4831 };
4832 let clarity = match msg.clarity {
4833 flatland_protocol::ChatClarity::Clear => "",
4834 flatland_protocol::ChatClarity::Partial => "~",
4835 flatland_protocol::ChatClarity::Heavy => "…",
4836 };
4837 self.state.push_log(format!(
4838 "[{label}{clarity}] {}: {}",
4839 msg.from_name, msg.text
4840 ));
4841 let now_ms = std::time::SystemTime::now()
4842 .duration_since(std::time::UNIX_EPOCH)
4843 .map(|d| d.as_millis() as u64)
4844 .unwrap_or(0);
4845 self.state
4846 .social_chat
4847 .note_speech(&msg, self.state.entity_id, now_ms);
4848 self.state
4849 .social_chat
4850 .push(crate::social::ChatLogEntry::from_message(
4851 msg,
4852 self.state.entity_id,
4853 ));
4854 }
4855 SessionEvent::TradeOpened(panel) => {
4856 self.state.social_chat.pending_trade = None;
4857 let peer = panel.peer_name.clone();
4858 self.state.trade_ui.open(panel);
4859 self.state
4860 .social_chat
4861 .push_system(format!("Trade open with {peer} — p present · r ready · Esc cancel"));
4862 self.state
4863 .social_chat
4864 .push_cue(crate::social::AudioCue::TradeOpened);
4865 }
4866 SessionEvent::TradeClosed { reason } => {
4867 self.state.push_log(reason.clone());
4868 self.state.social_chat.push_system(reason);
4869 self.state.trade_ui.close();
4870 }
4871 SessionEvent::HarvestResult(result) => {
4872 self.state.clear_harvest_state();
4873 crate::harvest_trace!(
4874 entity_id = self.state.entity_id,
4875 node_id = %result.node_id,
4876 template = %result.item_template,
4877 quantity = result.quantity,
4878 client_tick = self.state.tick,
4879 "client applied harvest result"
4880 );
4881 self.state.push_log(format!(
4882 "Harvested {} x{} (on the ground — press P to pick up)",
4883 result.item_template, result.quantity
4884 ));
4885 }
4886 SessionEvent::CraftResult(result) => {
4887 for stack in &result.consumed {
4888 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
4889 *qty = qty.saturating_sub(stack.quantity);
4890 if *qty == 0 {
4891 self.state.inventory.remove(&stack.template_id);
4892 }
4893 }
4894 }
4895 for stack in &result.outputs {
4896 *self
4897 .state
4898 .inventory
4899 .entry(stack.template_id.clone())
4900 .or_insert(0) += stack.quantity;
4901 }
4902 if let Some(output) = result.outputs.first() {
4903 if result.batch_total > 1 {
4904 self.state.push_log(format!(
4905 "Crafted {} x{} ({}/{})",
4906 output.template_id,
4907 output.quantity,
4908 result.batch_index,
4909 result.batch_total
4910 ));
4911 } else {
4912 self.state.push_log(format!(
4913 "Crafted {} x{}",
4914 output.template_id, output.quantity
4915 ));
4916 }
4917 } else {
4918 self.state
4919 .push_log(format!("Craft finished: {}", result.blueprint_id));
4920 }
4921 }
4922 SessionEvent::Death(notice) => {
4923 self.state.clear_harvest_state();
4924 self.state.push_log(notice.message.clone());
4925 self.state.push_log(format!(
4926 "Respawned at ({:.1}, {:.1})",
4927 notice.respawn_x, notice.respawn_y
4928 ));
4929 }
4930 SessionEvent::Interaction(notice) => {
4931 if notice.message.starts_with("Harvest failed:") {
4932 self.state.clear_harvest_state();
4933 }
4934 if notice.message.starts_with("Can't do that:") {
4935 self.state.pending_craft_ack = None;
4936 if let Some(pending) = self.state.pending_worker_job_ack.take() {
4937 if let Some(w) = self
4938 .state
4939 .hired_workers
4940 .iter_mut()
4941 .find(|w| w.instance_id == pending.worker_instance_id)
4942 {
4943 w.route = pending.prev_route;
4944 w.mode = pending.prev_mode;
4945 w.step_label = pending.prev_step_label;
4946 w.last_error = pending.prev_last_error;
4947 }
4948 let reason = notice
4949 .message
4950 .strip_prefix("Can't do that:")
4951 .unwrap_or(¬ice.message)
4952 .trim();
4953 self.state.push_log(format!(
4954 "Route save failed for {}: {reason}",
4955 pending.worker_label
4956 ));
4957 }
4958 }
4959 if notice.message.starts_with("Cast failed:") {
4960 self.state.cast_progress = None;
4961 }
4962 if notice.message.contains("slain the") {
4963 self.state.combat_target = None;
4964 self.state.combat_target_label = None;
4965 }
4966 if notice.message.contains("wants to trade") {
4968 if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
4969 let from_name = notice
4970 .message
4971 .split(" wants to trade")
4972 .next()
4973 .unwrap_or("Player")
4974 .to_string();
4975 self.state.social_chat.pending_trade =
4976 Some(crate::social::PendingTradeRequest {
4977 from_entity,
4978 from_name: from_name.clone(),
4979 });
4980 self.state.social_chat.push_system(format!(
4981 "{from_name} wants to trade — [Y] accept · [N] decline"
4982 ));
4983 self.state
4984 .social_chat
4985 .push_cue(crate::social::AudioCue::TradeOffer);
4986 }
4987 }
4988 if notice.message.starts_with("trade request declined") {
4989 self.state
4990 .social_chat
4991 .push_system(notice.message.clone());
4992 self.state
4993 .social_chat
4994 .push_cue(crate::social::AudioCue::TradeDeclined);
4995 }
4996 self.state.apply_interaction_notice(¬ice);
4997 self.state.push_log(notice.message.clone());
4998 }
4999 SessionEvent::ShopOpened(catalog) => {
5000 self.state.apply_shop_catalog(catalog);
5001 }
5002 SessionEvent::BankOpened(panel) => {
5003 self.state.apply_bank_panel(panel);
5004 }
5005 SessionEvent::StorageOpened(panel) => {
5006 self.state.apply_storage_panel(panel);
5007 }
5008 SessionEvent::NpcTalkOpened(opened) => {
5009 self.state.show_npc_verb_menu = false;
5010 if self.state.npc_verb_target.is_none() {
5011 self.state.npc_verb_target = Some(opened.npc_id.clone());
5012 }
5013 let label = opened.npc_label.clone();
5014 let banner = if !opened.trade_allowed {
5015 Some("Trade is unavailable right now.".to_string())
5016 } else {
5017 None
5018 };
5019 self.state.show_npc_chat = true;
5020 self.state.npc_chat = Some(NpcChatState {
5021 npc_id: opened.npc_id,
5022 npc_label: opened.npc_label,
5023 lines: if opened.greeting.is_empty() {
5024 vec![]
5025 } else {
5026 vec![format!("{label}: {}", opened.greeting)]
5027 },
5028 input: String::new(),
5029 pending: opened.greeting.is_empty(),
5030 talk_depth: opened.talk_depth,
5031 trade_allowed: opened.trade_allowed,
5032 banner,
5033 });
5034 }
5035 SessionEvent::NpcTalkPending(_) => {
5036 if let Some(chat) = self.state.npc_chat.as_mut() {
5037 chat.pending = true;
5038 }
5039 }
5040 SessionEvent::NpcTalkReply(reply) => {
5041 if let Some(chat) = self.state.npc_chat.as_mut() {
5042 if chat.npc_id == reply.npc_id {
5043 chat.pending = false;
5044 if reply.trade_disabled {
5045 chat.trade_allowed = false;
5046 chat.banner = Some("Trade is unavailable right now.".to_string());
5047 }
5048 if reply.wind_down {
5049 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
5050 if chat.banner.is_none() {
5051 chat.banner =
5052 Some("They're wrapping up — keep it brief.".to_string());
5053 }
5054 }
5055 chat.lines
5056 .push(format!("{}: {}", chat.npc_label, reply.line));
5057 }
5058 }
5059 }
5060 SessionEvent::NpcTalkClosed(closed) => {
5061 if self
5062 .state
5063 .npc_chat
5064 .as_ref()
5065 .is_some_and(|c| c.npc_id == closed.npc_id)
5066 {
5067 self.state.show_npc_chat = false;
5068 self.state.npc_chat = None;
5069 }
5070 }
5071 SessionEvent::NpcTalkError(err) => {
5072 self.state.push_log(format!("Talk failed: {}", err.reason));
5073 if let Some(chat) = self.state.npc_chat.as_mut() {
5074 chat.pending = false;
5075 }
5076 }
5077 SessionEvent::UseResult(result) => {
5078 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
5081 *qty = qty.saturating_sub(1);
5082 if *qty == 0 {
5083 self.state.inventory.remove(&result.template_id);
5084 }
5085 }
5086 }
5087 SessionEvent::QuestOffer(offer) => {
5088 self.state.pending_quest_offer = Some(offer.clone());
5089 self.state.show_quest_offer = true;
5090 self.state
5091 .push_log(format!("Quest offered: {}", offer.title));
5092 }
5093 SessionEvent::QuestAccepted(notice) => {
5094 self.state.show_quest_offer = false;
5095 self.state.pending_quest_offer = None;
5096 self.state.push_log(notice.message);
5097 }
5098 SessionEvent::QuestWithdrawn(notice) => {
5099 self.state.show_quest_menu = false;
5100 self.state.quest_withdraw_confirm = false;
5101 self.state.push_log(notice.message);
5102 }
5103 SessionEvent::QuestStepCompleted(notice) => {
5104 self.state.push_log(notice.message);
5105 }
5106 SessionEvent::QuestCompleted(notice) => {
5107 self.state.push_log(notice.message);
5108 }
5109 SessionEvent::Disconnected { reason } => {
5110 self.state.clear_harvest_state();
5111 self.state.connected = false;
5112 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
5113 if let Some(r) = &self.state.disconnect_reason {
5114 self.state.push_log(format!("Disconnected: {r}"));
5115 } else {
5116 self.state.push_log("Disconnected from server");
5117 }
5118 }
5119 }
5120 Ok(())
5121 }
5122
5123 pub fn is_connected(&self) -> bool {
5124 self.state.connected
5125 }
5126
5127 pub fn close_overlays(&mut self) {
5128 self.state.show_stats = false;
5129 self.state.show_craft_menu = false;
5130 self.state.show_shop_menu = false;
5131 self.state.shop_catalog = None;
5132 self.state.show_npc_verb_menu = false;
5133 self.state.npc_verb_target = None;
5134 self.state.show_npc_chat = false;
5135 self.state.npc_chat = None;
5136 self.state.show_inventory_menu = false;
5137 self.state.show_loadout_menu = false;
5138 self.state.show_rotation_editor = false;
5139 self.state.rotation_editor.reset();
5140 self.state.show_rename_prompt = false;
5141 self.state.show_worker_rename = false;
5142 self.state.rename_buffer.clear();
5143 self.state.show_move_picker = false;
5144 self.state.move_picker = None;
5145 self.state.show_destroy_picker = false;
5146 self.state.destroy_confirm_pending = false;
5147 self.state.destroy_picker = None;
5148 self.state.show_quest_offer = false;
5149 self.state.pending_quest_offer = None;
5150 self.state.show_quest_menu = false;
5151 self.state.quest_withdraw_confirm = false;
5152 self.state.show_workers_menu = false;
5153 self.close_worker_give_picker();
5154 self.close_worker_give_target_picker();
5155 self.close_worker_take_picker();
5156 self.close_worker_teach_picker();
5157 self.state.worker_route_editor = None;
5158 }
5159
5160 pub fn back_on_esc(&mut self) -> bool {
5162 if self.state.social_chat.composer_open() {
5163 self.state.social_chat.close_composer();
5164 return true;
5165 }
5166 if self.state.player_verbs.open {
5167 self.state.player_verbs.close();
5168 return true;
5169 }
5170 if self.state.whisper_pouch_ui.open {
5171 self.state.whisper_pouch_ui.open = false;
5172 return true;
5173 }
5174 if self.state.trade_ui.panel.is_some() {
5175 self.state.trade_ui.close();
5177 return true;
5178 }
5179 if self.state.show_rename_prompt {
5180 self.cancel_rename_prompt();
5181 return true;
5182 }
5183 if self.state.show_worker_rename {
5184 self.cancel_worker_rename();
5185 return true;
5186 }
5187 if self.state.show_destroy_picker {
5188 if self.state.destroy_confirm_pending {
5189 self.cancel_destroy_confirm();
5190 } else {
5191 self.close_destroy_picker();
5192 }
5193 return true;
5194 }
5195 if self.state.show_move_picker {
5196 self.close_move_picker();
5197 return true;
5198 }
5199 if self.state.show_rotation_editor {
5200 match self.state.rotation_editor.mode {
5201 RotationEditorMode::List => {
5202 self.state.show_rotation_editor = false;
5203 self.state.rotation_editor.reset();
5204 }
5205 RotationEditorMode::EditLabel => {
5206 self.state.rotation_editor.label_buffer.clear();
5207 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
5208 }
5209 RotationEditorMode::PickAbility => {
5210 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
5211 }
5212 RotationEditorMode::EditSequence => {
5213 self.state.rotation_editor.draft = None;
5214 self.state.rotation_editor.mode = RotationEditorMode::List;
5215 }
5216 }
5217 return true;
5218 }
5219 if self.state.show_inventory_menu {
5220 self.close_inventory_menu();
5221 return true;
5222 }
5223 if self.state.show_craft_menu {
5224 self.close_craft_menu();
5225 return true;
5226 }
5227 if self.state.show_quest_offer {
5228 self.quest_offer_decline();
5229 return true;
5230 }
5231 if self.state.show_shop_menu {
5232 return false;
5234 }
5235 if self.state.bank_panel.is_some() {
5236 return false;
5237 }
5238 if self.state.storage_panel.is_some() {
5239 return false;
5240 }
5241 if self.state.show_npc_chat {
5242 return false;
5244 }
5245 if self.state.show_npc_verb_menu {
5246 self.state.show_npc_verb_menu = false;
5247 self.state.npc_verb_target = None;
5248 return true;
5249 }
5250 if self.state.show_quest_menu {
5251 if self.state.quest_withdraw_confirm {
5252 self.state.quest_withdraw_confirm = false;
5253 } else {
5254 self.state.show_quest_menu = false;
5255 }
5256 return true;
5257 }
5258 if self.state.worker_route_editor.is_some() {
5259 if self.re_at_root_sheet() {
5261 self.close_worker_route_editor();
5262 } else {
5263 self.re_sheet_back();
5264 }
5265 return true;
5266 }
5267 if self.state.show_worker_give_picker {
5268 self.close_worker_give_picker();
5269 return true;
5270 }
5271 if self.state.show_worker_give_target_picker {
5272 self.close_worker_give_target_picker();
5273 return true;
5274 }
5275 if self.state.show_worker_take_picker {
5276 self.close_worker_take_picker();
5277 return true;
5278 }
5279 if self.state.show_worker_teach_picker {
5280 self.close_worker_teach_picker();
5281 return true;
5282 }
5283 if self.state.show_workers_menu {
5284 self.state.show_workers_menu = false;
5285 return true;
5286 }
5287 if self.state.show_loadout_menu {
5288 self.state.show_loadout_menu = false;
5289 return true;
5290 }
5291 if self.state.show_stats {
5292 self.state.show_stats = false;
5293 return true;
5294 }
5295 if self.state.show_equip_menu {
5296 self.state.show_equip_menu = false;
5297 return true;
5298 }
5299 false
5300 }
5301
5302 pub fn toggle_stats(&mut self) {
5303 self.state.show_stats = !self.state.show_stats;
5304 if self.state.show_stats {
5305 self.state.character_sheet_tab = CharacterSheetTab::Character;
5306 self.state.show_craft_menu = false;
5307 self.state.show_shop_menu = false;
5308 self.state.shop_catalog = None;
5309 self.state.show_inventory_menu = false;
5310 self.state.show_equip_menu = false;
5311 }
5312 }
5313
5314 pub fn toggle_equip_menu(&mut self) {
5315 self.state.show_equip_menu = !self.state.show_equip_menu;
5316 if self.state.show_equip_menu {
5317 self.state.show_stats = false;
5318 self.state.show_craft_menu = false;
5319 self.state.show_shop_menu = false;
5320 self.state.shop_catalog = None;
5321 self.state.show_inventory_menu = false;
5322 self.state.show_loadout_menu = false;
5323 }
5324 }
5325
5326 pub fn cycle_character_sheet_tab(&mut self) {
5327 if self.state.show_stats {
5328 self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
5329 }
5330 }
5331
5332 pub fn set_ledger_period_digit(&mut self, c: char) {
5333 if self.state.show_stats {
5334 if let Some(p) = LedgerPeriod::from_digit(c) {
5335 self.state.ledger_period = p;
5336 self.state.character_sheet_tab = CharacterSheetTab::Ledger;
5337 }
5338 }
5339 }
5340
5341 pub fn cycle_ledger_period(&mut self) {
5342 if self.state.show_stats
5343 && self.state.character_sheet_tab == CharacterSheetTab::Ledger
5344 {
5345 self.state.ledger_period = self.state.ledger_period.cycle();
5346 }
5347 }
5348
5349 pub fn open_inventory_menu(&mut self) {
5350 self.state.show_inventory_menu = true;
5351 self.state.show_craft_menu = false;
5352 self.state.show_shop_menu = false;
5353 self.state.shop_catalog = None;
5354 self.state.show_stats = false;
5355 self.state.show_move_picker = false;
5356 self.state.move_picker = None;
5357 self.state.show_destroy_picker = false;
5358 self.state.destroy_confirm_pending = false;
5359 self.state.destroy_picker = None;
5360 self.state.show_rename_prompt = false;
5361 self.state.rename_buffer.clear();
5362 self.state.inventory_filter_focused = false;
5363 self.state.clamp_inventory_indices();
5364 }
5365
5366 pub fn close_inventory_menu(&mut self) {
5367 self.state.show_inventory_menu = false;
5368 self.state.show_move_picker = false;
5369 self.state.move_picker = None;
5370 self.close_grant_picker();
5371 self.state.show_destroy_picker = false;
5372 self.state.destroy_confirm_pending = false;
5373 self.state.destroy_picker = None;
5374 self.state.show_rename_prompt = false;
5375 self.state.rename_buffer.clear();
5376 self.state.inventory_filter_focused = false;
5377 }
5378
5379 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
5380 let Some(row) = self.state.inventory_selected_row() else {
5381 anyhow::bail!("inventory empty");
5382 };
5383 if !self.state.row_is_renameable_container(&row) {
5384 anyhow::bail!("only storage containers can be renamed");
5385 }
5386 let current = row
5387 .stack
5388 .display_name
5389 .clone()
5390 .unwrap_or_else(|| row.stack.template_id.clone());
5391 self.state.rename_buffer = current;
5392 self.state.show_rename_prompt = true;
5393 self.state.show_worker_rename = false;
5394 self.state.show_move_picker = false;
5395 self.state.show_destroy_picker = false;
5396 self.state.destroy_confirm_pending = false;
5397 Ok(())
5398 }
5399
5400 pub fn cancel_rename_prompt(&mut self) {
5401 self.state.show_rename_prompt = false;
5402 self.state.rename_buffer.clear();
5403 }
5404
5405 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
5406 let name = self.state.rename_buffer.trim().to_string();
5407 if name.is_empty() {
5408 anyhow::bail!("name cannot be empty");
5409 }
5410 let Some(row) = self.state.inventory_selected_row() else {
5411 anyhow::bail!("inventory empty");
5412 };
5413 let Some(instance_id) = row.stack.item_instance_id else {
5414 anyhow::bail!("item has no instance id");
5415 };
5416 self.seq += 1;
5417 self.session
5418 .submit_intent(Intent::RenameContainer {
5419 entity_id: self.state.entity_id,
5420 item_instance_id: instance_id,
5421 location: row.from.clone(),
5422 name,
5423 seq: self.seq,
5424 })
5425 .await?;
5426 self.state.intents_sent += 1;
5427 self.state.show_rename_prompt = false;
5428 self.state.rename_buffer.clear();
5429 Ok(())
5430 }
5431
5432 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
5433 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
5434 anyhow::bail!("no worker selected");
5435 };
5436 self.state.rename_buffer = worker.label.clone();
5437 self.state.show_worker_rename = true;
5438 self.state.show_rename_prompt = false;
5439 Ok(())
5440 }
5441
5442 pub fn cancel_worker_rename(&mut self) {
5443 self.state.show_worker_rename = false;
5444 self.state.rename_buffer.clear();
5445 }
5446
5447 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
5448 let name = self.state.rename_buffer.trim().to_string();
5449 if name.is_empty() {
5450 anyhow::bail!("name cannot be empty");
5451 }
5452 if name.chars().count() > 32 {
5453 anyhow::bail!("name must be 1–32 characters");
5454 }
5455 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
5456 anyhow::bail!("no worker selected");
5457 };
5458 let worker_instance_id = worker.instance_id.clone();
5459 self.seq += 1;
5460 self.session
5461 .submit_intent(Intent::RenameHiredWorker {
5462 entity_id: self.state.entity_id,
5463 worker_instance_id: worker_instance_id.clone(),
5464 name: name.clone(),
5465 seq: self.seq,
5466 })
5467 .await?;
5468 self.state.intents_sent += 1;
5469 if let Some(w) = self
5470 .state
5471 .hired_workers
5472 .iter_mut()
5473 .find(|w| w.instance_id == worker_instance_id)
5474 {
5475 w.label = name.clone();
5476 }
5477 if let Some(ed) = self.state.worker_route_editor.as_mut() {
5478 if ed.worker_instance_id == worker_instance_id {
5479 ed.worker_label = name.clone();
5480 }
5481 }
5482 self.state.show_worker_rename = false;
5483 self.state.rename_buffer.clear();
5484 self.state.push_log(format!("Renamed worker to \"{name}\""));
5485 Ok(())
5486 }
5487
5488 pub fn toggle_inventory_menu(&mut self) {
5489 if self.state.show_inventory_menu {
5490 self.close_inventory_menu();
5491 } else {
5492 self.open_inventory_menu();
5493 }
5494 }
5495
5496 pub fn inventory_menu_move(&mut self, delta: i32) {
5498 if self.state.show_grant_picker {
5499 let Some(picker) = self.state.grant_picker.as_ref() else {
5500 return;
5501 };
5502 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5503 let filter = picker.filter.clone();
5504 let n = labels.len();
5505 if n == 0 {
5506 return;
5507 }
5508 self.state.grant_picker_index = step_filtered_index(
5509 self.state.grant_picker_index,
5510 delta,
5511 n,
5512 |i| list_label_matches(&labels[i], &filter),
5513 );
5514 return;
5515 }
5516 if self.state.show_move_picker {
5517 let Some(picker) = self.state.move_picker.as_ref() else {
5518 return;
5519 };
5520 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5521 let filter = picker.filter.clone();
5522 let n = labels.len();
5523 if n == 0 {
5524 return;
5525 }
5526 self.state.move_picker_index = step_filtered_index(
5527 self.state.move_picker_index,
5528 delta,
5529 n,
5530 |i| list_label_matches(&labels[i], &filter),
5531 );
5532 self.state.clamp_move_picker_quantity();
5533 return;
5534 }
5535 let n = self.state.inventory_selectable_rows().len();
5536 if n == 0 {
5537 return;
5538 }
5539 let idx = self.state.inventory_menu_index as i32;
5540 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
5541 }
5542
5543 pub fn inventory_menu_page(&mut self, pages: i32) {
5545 if self.state.show_grant_picker {
5546 let Some(picker) = self.state.grant_picker.as_ref() else {
5547 return;
5548 };
5549 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5550 let filter = picker.filter.clone();
5551 let n = labels.len();
5552 self.state.grant_picker_index = page_filtered_index(
5553 self.state.grant_picker_index,
5554 pages,
5555 n,
5556 |i| list_label_matches(&labels[i], &filter),
5557 );
5558 return;
5559 }
5560 if self.state.show_move_picker {
5561 let Some(picker) = self.state.move_picker.as_ref() else {
5562 return;
5563 };
5564 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5565 let filter = picker.filter.clone();
5566 let n = labels.len();
5567 self.state.move_picker_index = page_filtered_index(
5568 self.state.move_picker_index,
5569 pages,
5570 n,
5571 |i| list_label_matches(&labels[i], &filter),
5572 );
5573 self.state.clamp_move_picker_quantity();
5574 return;
5575 }
5576 let n = self.state.inventory_selectable_rows().len();
5577 self.state.inventory_menu_index =
5578 page_list_index(self.state.inventory_menu_index, pages, n);
5579 }
5580
5581 pub fn cycle_inventory_tab(&mut self, forward: bool) {
5582 if self.state.show_move_picker
5583 || self.state.show_grant_picker
5584 || self.state.show_destroy_picker
5585 || self.state.show_rename_prompt
5586 || self.state.inventory_filter_focused
5587 {
5588 return;
5589 }
5590 self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
5591 self.state.inventory_menu_index = 0;
5592 self.state.clamp_inventory_indices();
5593 }
5594
5595 pub fn focus_inventory_filter(&mut self) {
5596 if self.state.show_grant_picker {
5597 if let Some(p) = self.state.grant_picker.as_mut() {
5598 p.filter_focused = true;
5599 }
5600 return;
5601 }
5602 if self.state.show_move_picker {
5603 if let Some(p) = self.state.move_picker.as_mut() {
5604 p.filter_focused = true;
5605 }
5606 return;
5607 }
5608 self.state.inventory_filter_focused = true;
5609 }
5610
5611 pub fn set_inventory_filter(&mut self, filter: String) {
5612 self.state.inventory_filter = filter;
5613 self.state.inventory_menu_index = 0;
5614 self.state.clamp_inventory_indices();
5615 }
5616
5617 pub fn append_inventory_filter_char(&mut self, ch: char) {
5618 if ch.is_control() {
5619 return;
5620 }
5621 if self.state.show_grant_picker {
5622 if let Some(p) = self.state.grant_picker.as_mut() {
5623 if p.filter_focused {
5624 p.filter.push(ch);
5625 self.state.grant_picker_index = 0;
5626 }
5627 }
5628 return;
5629 }
5630 if self.state.show_move_picker {
5631 if let Some(p) = self.state.move_picker.as_mut() {
5632 if p.filter_focused {
5633 p.filter.push(ch);
5634 self.state.move_picker_index = 0;
5635 self.state.clamp_move_picker_quantity();
5636 }
5637 }
5638 return;
5639 }
5640 if !self.state.inventory_filter_focused {
5641 return;
5642 }
5643 self.state.inventory_filter.push(ch);
5644 self.state.inventory_menu_index = 0;
5645 self.state.clamp_inventory_indices();
5646 }
5647
5648 pub fn inventory_filter_backspace(&mut self) {
5649 if self.state.show_grant_picker {
5650 if let Some(p) = self.state.grant_picker.as_mut() {
5651 if p.filter_focused {
5652 p.filter.pop();
5653 self.state.grant_picker_index = 0;
5654 }
5655 }
5656 return;
5657 }
5658 if self.state.show_move_picker {
5659 if let Some(p) = self.state.move_picker.as_mut() {
5660 if p.filter_focused {
5661 p.filter.pop();
5662 self.state.move_picker_index = 0;
5663 self.state.clamp_move_picker_quantity();
5664 }
5665 }
5666 return;
5667 }
5668 if !self.state.inventory_filter_focused {
5669 return;
5670 }
5671 self.state.inventory_filter.pop();
5672 self.state.inventory_menu_index = 0;
5673 self.state.clamp_inventory_indices();
5674 }
5675
5676 pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
5678 if self.state.show_grant_picker {
5679 if let Some(p) = self.state.grant_picker.as_mut() {
5680 if p.filter_focused {
5681 if !p.filter.is_empty() {
5682 p.filter.clear();
5683 self.state.grant_picker_index = 0;
5684 } else {
5685 p.filter_focused = false;
5686 }
5687 return true;
5688 }
5689 if !p.filter.is_empty() {
5690 p.filter.clear();
5691 self.state.grant_picker_index = 0;
5692 return true;
5693 }
5694 }
5695 return false;
5696 }
5697 if self.state.show_move_picker {
5698 if let Some(p) = self.state.move_picker.as_mut() {
5699 if p.filter_focused {
5700 if !p.filter.is_empty() {
5701 p.filter.clear();
5702 self.state.move_picker_index = 0;
5703 self.state.clamp_move_picker_quantity();
5704 } else {
5705 p.filter_focused = false;
5706 }
5707 return true;
5708 }
5709 if !p.filter.is_empty() {
5710 p.filter.clear();
5711 self.state.move_picker_index = 0;
5712 self.state.clamp_move_picker_quantity();
5713 return true;
5714 }
5715 }
5716 return false;
5717 }
5718 if self.state.inventory_filter_focused {
5719 if !self.state.inventory_filter.is_empty() {
5720 self.state.inventory_filter.clear();
5721 self.state.inventory_menu_index = 0;
5722 self.state.clamp_inventory_indices();
5723 } else {
5724 self.state.inventory_filter_focused = false;
5725 }
5726 return true;
5727 }
5728 if !self.state.inventory_filter.is_empty() {
5729 self.state.inventory_filter.clear();
5730 self.state.inventory_menu_index = 0;
5731 self.state.clamp_inventory_indices();
5732 return true;
5733 }
5734 false
5735 }
5736
5737 pub fn craft_menu_page(&mut self, pages: i32) {
5738 let n = self.state.blueprints.len();
5739 self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
5740 self.state.clamp_craft_batch_quantity();
5741 }
5742
5743 pub fn shop_menu_page(&mut self, pages: i32) {
5744 let n = self.state.shop_list_len();
5745 self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
5746 self.state.clamp_shop_quantity();
5747 }
5748
5749 pub fn workers_menu_page(&mut self, pages: i32) {
5750 let n = self.state.hired_workers.len();
5751 self.state.workers_menu_index =
5752 page_list_index(self.state.workers_menu_index, pages, n);
5753 }
5754
5755 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
5760 if self.state.show_destroy_picker {
5761 if self.state.destroy_confirm_pending {
5762 return self.confirm_destroy_item().await;
5763 }
5764 return self.request_destroy_confirm();
5765 }
5766 if self.state.show_grant_picker {
5767 return self.confirm_grant_picker().await;
5768 }
5769 if self.state.show_move_picker {
5770 return self.confirm_move_picker().await;
5771 }
5772 let Some(row) = self.state.inventory_selected_row() else {
5773 anyhow::bail!("inventory empty");
5774 };
5775 if row.is_equip_shell {
5776 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
5777 anyhow::bail!("not a worn item");
5778 };
5779 return self.equip_worn(slot, None).await;
5780 }
5781 if row.is_chest_shell {
5782 return self.open_chest_pickup_picker();
5783 }
5784 let template_id = row.stack.template_id.clone();
5785 let instance_id = row.stack.item_instance_id;
5786 let category = self.state.inventory_item_category(&template_id);
5787 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
5788
5789 if category == Some("weapon") {
5790 return self.equip_mainhand(Some(template_id)).await;
5791 }
5792 if category == Some("lodging") && on_person {
5793 if let Some(inst) = instance_id {
5794 return self.place_container(inst).await;
5795 }
5796 }
5797 if (category == Some("container") || category == Some("armor")) && on_person {
5798 if let Some(inst) = instance_id {
5799 let world_placeable = row.stack.world_placeable == Some(true)
5800 || template_id.contains("chest");
5801 if world_placeable {
5802 return self.place_container(inst).await;
5803 }
5804 if let Some(slot) = guess_body_slot(&template_id) {
5808 return self.equip_worn(slot, Some(inst)).await;
5809 }
5810 }
5811 }
5812 self.open_move_picker()
5816 }
5817
5818 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
5820 let Some(row) = self.state.inventory_selected_row() else {
5821 anyhow::bail!("inventory empty");
5822 };
5823 if row.from != flatland_protocol::InventoryLocation::Root {
5824 anyhow::bail!("select a consumable on your person");
5825 }
5826 if GameState::stack_is_item_grant(&row.stack) {
5827 return self.open_grant_target_picker();
5828 }
5829 let category = self
5830 .state
5831 .inventory_item_category(&row.stack.template_id);
5832 if category != Some("consumable") {
5833 anyhow::bail!("selected item is not consumable");
5834 }
5835 self.use_item(&row.stack.template_id).await
5836 }
5837
5838 pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
5840 let Some(row) = self.state.inventory_selected_row() else {
5841 anyhow::bail!("inventory empty");
5842 };
5843 if row.from != flatland_protocol::InventoryLocation::Root {
5844 anyhow::bail!("select a grant item on your person");
5845 }
5846 if !GameState::stack_is_item_grant(&row.stack) {
5847 anyhow::bail!("selected item does not grant onto gear");
5848 }
5849 let Some(grant_instance_id) = row.stack.item_instance_id else {
5850 anyhow::bail!("grant has no instance id");
5851 };
5852 let effect_id = GameState::grant_effect_id(&row.stack)
5853 .unwrap_or("?")
5854 .to_string();
5855 let mode = GameState::grant_mode(&row.stack).to_string();
5856 let options = self.state.grant_target_options(&row.stack);
5857 if options.is_empty() {
5858 anyhow::bail!("no valid gear to apply {effect_id} to");
5859 }
5860 let grant_label = row
5861 .stack
5862 .display_name
5863 .clone()
5864 .unwrap_or_else(|| row.stack.template_id.clone());
5865 self.state.show_grant_picker = true;
5866 self.state.grant_picker_index = 0;
5867 self.state.grant_picker = Some(GrantTargetPicker {
5868 grant_instance_id,
5869 grant_label,
5870 effect_id,
5871 mode,
5872 options,
5873 filter: String::new(),
5874 filter_focused: false,
5875 });
5876 Ok(())
5877 }
5878
5879 pub fn close_grant_picker(&mut self) {
5880 self.state.show_grant_picker = false;
5881 self.state.grant_picker = None;
5882 self.state.grant_picker_index = 0;
5883 }
5884
5885 pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
5886 let Some(picker) = self.state.grant_picker.clone() else {
5887 self.close_grant_picker();
5888 return Ok(());
5889 };
5890 let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
5891 self.close_grant_picker();
5892 return Ok(());
5893 };
5894 self.close_grant_picker();
5895 self.use_grant(picker.grant_instance_id, opt.target_instance_id)
5896 .await?;
5897 self.state.push_log(format!(
5898 "Applying {} onto {}…",
5899 picker.effect_id, opt.label
5900 ));
5901 Ok(())
5902 }
5903
5904 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
5908 let Some(row) = self.state.inventory_selected_row() else {
5909 anyhow::bail!("inventory empty");
5910 };
5911 if row.is_equip_shell {
5912 anyhow::bail!("this is a worn bag — press Enter to unequip it");
5913 }
5914 if row.is_chest_shell {
5915 return self.open_chest_pickup_picker();
5916 }
5917 let Some(instance_id) = row.stack.item_instance_id else {
5918 anyhow::bail!("item has no instance id");
5919 };
5920 let mut options = self.state.move_destinations_for(
5921 &row.from,
5922 row.from_parent_instance_id,
5923 row.stack.item_instance_id,
5924 &row.stack.template_id,
5925 );
5926 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
5927 let category = self.state.inventory_item_category(&row.stack.template_id);
5928 if on_person && category == Some("consumable") {
5929 if GameState::stack_is_item_grant(&row.stack) {
5930 options.insert(
5931 0,
5932 MoveOption {
5933 label: "Apply onto gear…".into(),
5934 kind: MoveOptionKind::GrantApply,
5935 },
5936 );
5937 } else {
5938 options.insert(
5939 0,
5940 MoveOption {
5941 label: "Use (eat / drink)".into(),
5942 kind: MoveOptionKind::Use,
5943 },
5944 );
5945 }
5946 }
5947 let item_label = row
5948 .stack
5949 .display_name
5950 .clone()
5951 .unwrap_or_else(|| row.stack.template_id.clone());
5952 let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
5955 self.state.move_picker = Some(MovePicker {
5956 item_instance_id: instance_id,
5957 from: row.from,
5958 item_label,
5959 template_id: row.stack.template_id.clone(),
5960 stack_quantity: row.stack.quantity,
5961 quantity: initial_qty.max(1),
5962 options,
5963 filter: String::new(),
5964 filter_focused: false,
5965 });
5966 self.state.move_picker_index = 0;
5967 self.state.show_move_picker = true;
5968 self.state.show_destroy_picker = false;
5969 self.state.destroy_confirm_pending = false;
5970 self.state.destroy_picker = None;
5971 self.state.clamp_move_picker_quantity();
5972 Ok(())
5973 }
5974
5975 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
5977 let Some(row) = self.state.inventory_selected_row() else {
5978 anyhow::bail!("inventory empty");
5979 };
5980 if !row.is_chest_shell {
5981 anyhow::bail!("not a placed chest");
5982 }
5983 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
5984 anyhow::bail!("not a placed chest");
5985 };
5986 let Some(instance_id) = row.stack.item_instance_id else {
5987 anyhow::bail!("chest has no instance id");
5988 };
5989 let chest = self
5990 .state
5991 .placed_containers
5992 .iter()
5993 .find(|c| c.id == *container_id)
5994 .cloned()
5995 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
5996 let (px, py) = self.state.player_position();
5997 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
5998 anyhow::bail!("too far from {}", chest.display_name);
5999 }
6000 if chest.locked && !chest.accessible {
6001 anyhow::bail!(
6002 "need the matching key for {} before picking it up",
6003 chest.display_name
6004 );
6005 }
6006 let options = self.state.chest_pickup_destinations(container_id);
6007 let item_label = row
6008 .stack
6009 .display_name
6010 .clone()
6011 .unwrap_or_else(|| row.stack.template_id.clone());
6012 self.state.move_picker = Some(MovePicker {
6013 item_instance_id: instance_id,
6014 from: row.from.clone(),
6015 item_label,
6016 template_id: row.stack.template_id.clone(),
6017 stack_quantity: 1,
6018 quantity: 1,
6019 options,
6020 filter: String::new(),
6021 filter_focused: false,
6022 });
6023 self.state.move_picker_index = 0;
6024 self.state.show_move_picker = true;
6025 self.state.show_destroy_picker = false;
6026 self.state.destroy_confirm_pending = false;
6027 self.state.destroy_picker = None;
6028 Ok(())
6029 }
6030
6031 pub fn close_move_picker(&mut self) {
6032 self.state.show_move_picker = false;
6033 self.state.move_picker = None;
6034 }
6035
6036 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
6037 self.state.move_picker_adjust_quantity(delta);
6038 }
6039
6040 pub fn move_picker_set_quantity_max(&mut self) {
6041 self.state.move_picker_set_quantity_max();
6042 }
6043
6044 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
6045 self.state.destroy_picker_adjust_quantity(delta);
6046 }
6047
6048 pub fn destroy_picker_set_quantity_max(&mut self) {
6049 self.state.destroy_picker_set_quantity_max();
6050 }
6051
6052 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
6053 let Some(picker) = self.state.move_picker.clone() else {
6054 self.close_move_picker();
6055 return Ok(());
6056 };
6057 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
6058 self.close_move_picker();
6059 return Ok(());
6060 };
6061 match option.kind {
6062 MoveOptionKind::Cancel => {
6063 self.close_move_picker();
6064 }
6065 MoveOptionKind::Use => {
6066 self.close_move_picker();
6067 self.use_item(&picker.template_id).await?;
6068 }
6069 MoveOptionKind::GrantApply => {
6070 self.close_move_picker();
6071 self.open_grant_target_picker()?;
6072 }
6073 MoveOptionKind::Drop => {
6074 self.close_move_picker();
6075 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
6076 if self.state.key_drop_blocked(&stack) {
6077 anyhow::bail!("cannot drop the key while its chest is locked");
6078 }
6079 }
6080 self.drop_item(picker.item_instance_id, picker.from).await?;
6081 self.state
6082 .push_log(format!("Dropped {}", picker.item_label));
6083 }
6084 MoveOptionKind::PickupPlaced {
6085 container_id,
6086 nest_location,
6087 nest_parent_instance_id,
6088 } => {
6089 self.close_move_picker();
6090 self.pickup_container(container_id.clone()).await?;
6091 let nest_into_bag = nest_parent_instance_id.is_some()
6092 || !matches!(
6093 nest_location,
6094 flatland_protocol::InventoryLocation::Root
6095 );
6096 if nest_into_bag {
6097 self.move_item(
6098 picker.item_instance_id,
6099 flatland_protocol::InventoryLocation::Root,
6100 nest_location,
6101 nest_parent_instance_id,
6102 None,
6103 )
6104 .await?;
6105 self.state
6106 .push_log(format!("Picked up {} into bag", picker.item_label));
6107 } else {
6108 self.state
6109 .push_log(format!("Picked up {}", picker.item_label));
6110 }
6111 }
6112 MoveOptionKind::Move {
6113 location,
6114 parent_instance_id,
6115 } => {
6116 self.close_move_picker();
6117 let qty = if picker.quantity >= picker.stack_quantity {
6118 None
6119 } else {
6120 Some(picker.quantity)
6121 };
6122 self.move_item(
6123 picker.item_instance_id,
6124 picker.from,
6125 location,
6126 parent_instance_id,
6127 qty,
6128 )
6129 .await?;
6130 let moved = qty.unwrap_or(picker.stack_quantity);
6131 if moved >= picker.stack_quantity {
6132 self.state.push_log(format!("Moved {}", picker.item_label));
6133 } else {
6134 self.state.push_log(format!(
6135 "Moved {} ×{} of {}",
6136 picker.item_label, moved, picker.stack_quantity
6137 ));
6138 }
6139 }
6140 }
6141 Ok(())
6142 }
6143
6144 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
6146 let Some(row) = self.state.inventory_selected_row() else {
6147 anyhow::bail!("inventory empty");
6148 };
6149 if row.is_equip_shell {
6150 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
6151 }
6152 if row.is_chest_shell {
6153 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
6154 }
6155 let Some(inst) = row.stack.item_instance_id else {
6156 anyhow::bail!("item has no instance id");
6157 };
6158 if self.state.key_drop_blocked(&row.stack) {
6159 anyhow::bail!("cannot drop the key while its chest is locked");
6160 }
6161 let label = row
6162 .stack
6163 .display_name
6164 .clone()
6165 .unwrap_or_else(|| row.stack.template_id.clone());
6166 self.drop_item(inst, row.from).await?;
6167 self.state.push_log(format!("Dropped {label}"));
6168 Ok(())
6169 }
6170
6171 pub async fn drop_item(
6172 &mut self,
6173 item_instance_id: uuid::Uuid,
6174 from: flatland_protocol::InventoryLocation,
6175 ) -> anyhow::Result<()> {
6176 self.seq += 1;
6177 self.session
6178 .submit_intent(Intent::DropItem {
6179 entity_id: self.state.entity_id,
6180 item_instance_id,
6181 from,
6182 seq: self.seq,
6183 })
6184 .await?;
6185 self.state.intents_sent += 1;
6186 Ok(())
6187 }
6188
6189 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
6191 let Some(row) = self.state.inventory_selected_row() else {
6192 anyhow::bail!("inventory empty");
6193 };
6194 if row.is_equip_shell {
6195 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
6196 }
6197 if row.is_chest_shell {
6198 anyhow::bail!("can't destroy a placed chest from the inventory list");
6199 }
6200 let Some(instance_id) = row.stack.item_instance_id else {
6201 anyhow::bail!("item has no instance id");
6202 };
6203 if self.state.key_drop_blocked(&row.stack) {
6204 anyhow::bail!("cannot destroy the key while its chest is locked");
6205 }
6206 let item_label = row
6207 .stack
6208 .display_name
6209 .clone()
6210 .unwrap_or_else(|| row.stack.template_id.clone());
6211 self.state.destroy_picker = Some(DestroyPicker {
6212 item_instance_id: instance_id,
6213 from: row.from,
6214 item_label,
6215 stack_quantity: row.stack.quantity,
6216 quantity: row.stack.quantity,
6217 });
6218 self.state.destroy_confirm_pending = false;
6219 self.state.show_destroy_picker = true;
6220 self.state.show_move_picker = false;
6221 self.state.move_picker = None;
6222 Ok(())
6223 }
6224
6225 pub fn close_destroy_picker(&mut self) {
6226 self.state.show_destroy_picker = false;
6227 self.state.destroy_confirm_pending = false;
6228 self.state.destroy_picker = None;
6229 }
6230
6231 pub fn cancel_destroy_confirm(&mut self) {
6232 self.state.destroy_confirm_pending = false;
6233 }
6234
6235 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
6236 if self.state.destroy_picker.is_none() {
6237 self.close_destroy_picker();
6238 return Ok(());
6239 }
6240 self.state.destroy_confirm_pending = true;
6241 Ok(())
6242 }
6243
6244 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
6245 let Some(picker) = self.state.destroy_picker.clone() else {
6246 self.close_destroy_picker();
6247 return Ok(());
6248 };
6249 let qty = if picker.quantity >= picker.stack_quantity {
6250 None
6251 } else {
6252 Some(picker.quantity)
6253 };
6254 self.destroy_item(picker.item_instance_id, picker.from, qty)
6255 .await?;
6256 let destroyed = qty.unwrap_or(picker.stack_quantity);
6257 if destroyed >= picker.stack_quantity {
6258 self.state
6259 .push_log(format!("Destroyed {}", picker.item_label));
6260 } else {
6261 self.state.push_log(format!(
6262 "Destroyed {} ×{} of {}",
6263 picker.item_label, destroyed, picker.stack_quantity
6264 ));
6265 }
6266 self.close_destroy_picker();
6267 Ok(())
6268 }
6269
6270 pub async fn destroy_item(
6271 &mut self,
6272 item_instance_id: uuid::Uuid,
6273 from: flatland_protocol::InventoryLocation,
6274 quantity: Option<u32>,
6275 ) -> anyhow::Result<()> {
6276 self.seq += 1;
6277 self.session
6278 .submit_intent(Intent::DestroyItem {
6279 entity_id: self.state.entity_id,
6280 item_instance_id,
6281 from,
6282 quantity,
6283 seq: self.seq,
6284 })
6285 .await?;
6286 self.state.intents_sent += 1;
6287 Ok(())
6288 }
6289
6290 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
6292 if let Some(row) = self.state.inventory_selected_row() {
6293 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
6294 return self.toggle_placed_chest_lock(container_id).await;
6295 }
6296 }
6297 self.toggle_nearby_chest_lock().await
6298 }
6299
6300 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
6301 let chest = self
6302 .state
6303 .placed_containers
6304 .iter()
6305 .find(|c| c.id == container_id)
6306 .cloned()
6307 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
6308 let (px, py) = self.state.player_position();
6309 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
6310 anyhow::bail!("too far from {}", chest.display_name);
6311 }
6312 if !chest.accessible && chest.locked {
6313 anyhow::bail!(
6314 "need the matching key for {} (each crafted chest has its own key)",
6315 chest.display_name
6316 );
6317 }
6318 let lock = !chest.locked;
6319 self.set_container_locked(
6320 flatland_protocol::InventoryLocation::Placed {
6321 container_id: chest.id.clone(),
6322 },
6323 lock,
6324 )
6325 .await?;
6326 self.state.push_log(if lock {
6327 format!("Locked {}", chest.display_name)
6328 } else {
6329 format!("Unlocked {}", chest.display_name)
6330 });
6331 Ok(())
6332 }
6333
6334 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
6336 let chest = self
6337 .state
6338 .nearest_placed_container(CONTAINER_RANGE_M)
6339 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
6340 self.toggle_placed_chest_lock(&chest.id).await
6341 }
6342
6343 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
6344 self.equip_mainhand(None).await
6345 }
6346
6347 pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
6348 if !self.state.is_alive() {
6349 anyhow::bail!("you are dead");
6350 }
6351 self.seq += 1;
6352 self.session
6353 .submit_intent(Intent::EquipOffhand {
6354 entity_id: self.state.entity_id,
6355 template_id,
6356 instance_id: None,
6357 seq: self.seq,
6358 })
6359 .await?;
6360 self.state.intents_sent += 1;
6361 Ok(())
6362 }
6363
6364 pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
6365 self.equip_offhand(None).await
6366 }
6367
6368 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
6369 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
6370 for slot in slots {
6371 self.equip_worn(slot, None).await?;
6372 }
6373 Ok(())
6374 }
6375
6376 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
6377 let (px, py) = self.state.player_position();
6378 let nearest = self
6379 .state
6380 .placed_containers
6381 .iter()
6382 .min_by(|a, b| {
6383 let da = (a.x - px).hypot(a.y - py);
6384 let db = (b.x - px).hypot(b.y - py);
6385 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
6386 })
6387 .cloned();
6388 let Some(chest) = nearest else {
6389 anyhow::bail!("no chest nearby");
6390 };
6391 if (chest.x - px).hypot(chest.y - py) > 2.0 {
6392 anyhow::bail!("too far from chest");
6393 }
6394 self.pickup_container(chest.id).await
6395 }
6396
6397 pub async fn equip_worn(
6398 &mut self,
6399 slot: BodySlot,
6400 instance_id: Option<uuid::Uuid>,
6401 ) -> anyhow::Result<()> {
6402 self.seq += 1;
6403 self.session
6404 .submit_intent(Intent::EquipWorn {
6405 entity_id: self.state.entity_id,
6406 slot,
6407 instance_id,
6408 seq: self.seq,
6409 })
6410 .await?;
6411 self.state.intents_sent += 1;
6412 Ok(())
6413 }
6414
6415 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
6416 self.seq += 1;
6417 self.session
6418 .submit_intent(Intent::PlaceContainer {
6419 entity_id: self.state.entity_id,
6420 item_instance_id,
6421 seq: self.seq,
6422 })
6423 .await?;
6424 self.state.intents_sent += 1;
6425 Ok(())
6426 }
6427
6428 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
6429 self.seq += 1;
6430 self.session
6431 .submit_intent(Intent::PickupContainer {
6432 entity_id: self.state.entity_id,
6433 container_id,
6434 seq: self.seq,
6435 })
6436 .await?;
6437 self.state.intents_sent += 1;
6438 Ok(())
6439 }
6440
6441 pub async fn move_item(
6442 &mut self,
6443 item_instance_id: uuid::Uuid,
6444 from: flatland_protocol::InventoryLocation,
6445 to: flatland_protocol::InventoryLocation,
6446 to_parent_instance_id: Option<uuid::Uuid>,
6447 quantity: Option<u32>,
6448 ) -> anyhow::Result<()> {
6449 self.seq += 1;
6450 self.session
6451 .submit_intent(Intent::MoveItem {
6452 entity_id: self.state.entity_id,
6453 item_instance_id,
6454 from,
6455 to,
6456 to_parent_instance_id,
6457 quantity,
6458 seq: self.seq,
6459 })
6460 .await?;
6461 self.state.intents_sent += 1;
6462 Ok(())
6463 }
6464
6465 pub async fn set_container_locked(
6466 &mut self,
6467 location: flatland_protocol::InventoryLocation,
6468 locked: bool,
6469 ) -> anyhow::Result<()> {
6470 self.seq += 1;
6471 self.session
6472 .submit_intent(Intent::SetContainerLocked {
6473 entity_id: self.state.entity_id,
6474 location,
6475 locked,
6476 seq: self.seq,
6477 })
6478 .await?;
6479 self.state.intents_sent += 1;
6480 Ok(())
6481 }
6482
6483 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
6484 if !self.state.is_alive() {
6485 anyhow::bail!("you are dead");
6486 }
6487 self.seq += 1;
6488 self.session
6489 .submit_intent(Intent::Use {
6490 entity_id: self.state.entity_id,
6491 template_id: template_id.to_string(),
6492 seq: self.seq,
6493 })
6494 .await?;
6495 self.state.intents_sent += 1;
6496 Ok(())
6497 }
6498
6499 pub async fn use_grant(
6501 &mut self,
6502 grant_instance_id: uuid::Uuid,
6503 target_instance_id: uuid::Uuid,
6504 ) -> anyhow::Result<()> {
6505 if !self.state.is_alive() {
6506 anyhow::bail!("you are dead");
6507 }
6508 self.seq += 1;
6509 self.session
6510 .submit_intent(Intent::UseGrant {
6511 entity_id: self.state.entity_id,
6512 grant_instance_id,
6513 target_instance_id,
6514 seq: self.seq,
6515 })
6516 .await?;
6517 self.state.intents_sent += 1;
6518 Ok(())
6519 }
6520
6521 pub fn open_craft_menu(&mut self) {
6522 self.state.show_craft_menu = true;
6523 self.state.show_shop_menu = false;
6524 self.state.shop_catalog = None;
6525 self.state.show_stats = false;
6526 self.state.show_inventory_menu = false;
6527 if self.state.blueprints.is_empty() {
6528 self.state.craft_menu_index = 0;
6529 self.state.craft_batch_quantity = 1;
6530 return;
6531 }
6532 self.state.craft_menu_index = self
6533 .state
6534 .craft_menu_index
6535 .min(self.state.blueprints.len() - 1);
6536 if let Some(idx) = self
6537 .state
6538 .blueprints
6539 .iter()
6540 .position(|bp| self.state.can_craft_blueprint(bp))
6541 {
6542 self.state.craft_menu_index = idx;
6543 }
6544 self.state.clamp_craft_batch_quantity();
6545 }
6546
6547 pub fn close_craft_menu(&mut self) {
6548 self.state.show_craft_menu = false;
6549 }
6550
6551 pub fn toggle_keychain_menu(&mut self) {
6552 if self.state.show_keychain_menu {
6553 self.close_keychain_menu();
6554 } else {
6555 self.state.show_keychain_menu = true;
6556 self.state.show_craft_menu = false;
6557 self.state.show_shop_menu = false;
6558 self.state.show_inventory_menu = false;
6559 let n = self.state.keychain_entries().len();
6560 if n == 0 {
6561 self.state.keychain_menu_index = 0;
6562 } else {
6563 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
6564 }
6565 }
6566 }
6567
6568 pub fn close_keychain_menu(&mut self) {
6569 self.state.show_keychain_menu = false;
6570 }
6571
6572 pub fn keychain_menu_move(&mut self, delta: i32) {
6573 let n = self.state.keychain_entries().len();
6574 if n == 0 {
6575 self.state.keychain_menu_index = 0;
6576 return;
6577 }
6578 let idx = self.state.keychain_menu_index as i32 + delta;
6579 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
6580 }
6581
6582 pub fn keychain_menu_page(&mut self, pages: i32) {
6583 let n = self.state.keychain_entries().len();
6584 self.state.keychain_menu_index =
6585 page_list_index(self.state.keychain_menu_index, pages, n);
6586 }
6587
6588 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
6589 if !self.state.is_alive() {
6590 anyhow::bail!("you are dead");
6591 }
6592 let entries = self.state.keychain_entries();
6593 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
6594 anyhow::bail!("nothing selected");
6595 };
6596 let Some(instance_id) = entry.stack.item_instance_id else {
6597 anyhow::bail!("key has no instance id");
6598 };
6599 if entry.stowed {
6600 self.move_item(
6601 instance_id,
6602 flatland_protocol::InventoryLocation::Keychain,
6603 flatland_protocol::InventoryLocation::Root,
6604 None,
6605 Some(1),
6606 )
6607 .await
6608 } else {
6609 self.move_item(
6610 instance_id,
6611 flatland_protocol::InventoryLocation::Root,
6612 flatland_protocol::InventoryLocation::Keychain,
6613 None,
6614 Some(1),
6615 )
6616 .await
6617 }
6618 }
6619
6620 pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
6621 let npc_id = self
6622 .state
6623 .shop_catalog
6624 .as_ref()
6625 .map(|c| c.npc_id.clone());
6626 self.state.show_shop_menu = false;
6627 self.state.shop_catalog = None;
6628 self.state.clear_shop_trade_log();
6629 if let Some(npc_id) = npc_id {
6630 self.seq += 1;
6631 self.session
6632 .submit_intent(Intent::ShopClose {
6633 entity_id: self.state.entity_id,
6634 npc_id,
6635 seq: self.seq,
6636 })
6637 .await?;
6638 self.state.intents_sent += 1;
6639 }
6640 Ok(())
6641 }
6642
6643 pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
6644 let Some(panel) = self.state.bank_panel.clone() else {
6645 return Ok(());
6646 };
6647 self.seq += 1;
6648 self.session
6649 .submit_intent(Intent::BankDeposit {
6650 entity_id: self.state.entity_id,
6651 npc_id: panel.npc_id,
6652 amount_copper,
6653 seq: self.seq,
6654 })
6655 .await?;
6656 self.state.intents_sent += 1;
6657 Ok(())
6658 }
6659
6660 pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
6661 let Some(panel) = self.state.bank_panel.clone() else {
6662 return Ok(());
6663 };
6664 self.seq += 1;
6665 self.session
6666 .submit_intent(Intent::BankWithdraw {
6667 entity_id: self.state.entity_id,
6668 npc_id: panel.npc_id,
6669 amount_copper,
6670 seq: self.seq,
6671 })
6672 .await?;
6673 self.state.intents_sent += 1;
6674 Ok(())
6675 }
6676
6677 pub async fn bank_transfer(
6678 &mut self,
6679 to_character_id: Option<uuid::Uuid>,
6680 to_name: String,
6681 amount_copper: u64,
6682 ) -> anyhow::Result<()> {
6683 let Some(panel) = self.state.bank_panel.clone() else {
6684 return Ok(());
6685 };
6686 self.seq += 1;
6687 self.session
6688 .submit_intent(Intent::BankTransfer {
6689 entity_id: self.state.entity_id,
6690 npc_id: panel.npc_id,
6691 to_character_id,
6692 to_name,
6693 amount_copper,
6694 seq: self.seq,
6695 })
6696 .await?;
6697 self.state.intents_sent += 1;
6698 Ok(())
6699 }
6700
6701 pub fn bank_menu_move(&mut self, delta: i32) {
6702 let n = self.state.bank_menu_options().len();
6703 if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
6704 return;
6705 }
6706 let idx = self.state.bank_menu_index as i32;
6707 self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6708 }
6709
6710 pub fn storage_menu_move(&mut self, delta: i32) {
6711 let n = self.state.storage_menu_options().len();
6712 if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
6713 return;
6714 }
6715 let idx = self.state.storage_menu_index as i32;
6716 self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6717 }
6718
6719 pub fn storage_pick_move(&mut self, delta: i32) {
6720 let n = match &self.state.storage_ui_mode {
6721 StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
6722 StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
6723 self.state.storage_vault_options().len()
6724 }
6725 StorageUiMode::Menu
6726 | StorageUiMode::StoreAmount { .. }
6727 | StorageUiMode::TakeAmount { .. }
6728 | StorageUiMode::ShipAmount { .. } => 0,
6729 };
6730 if n == 0 {
6731 return;
6732 }
6733 match &mut self.state.storage_ui_mode {
6734 StorageUiMode::StorePick { index }
6735 | StorageUiMode::TakePick { index }
6736 | StorageUiMode::ShipPick { index, .. } => {
6737 *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
6738 }
6739 StorageUiMode::Menu
6740 | StorageUiMode::StoreAmount { .. }
6741 | StorageUiMode::TakeAmount { .. }
6742 | StorageUiMode::ShipAmount { .. } => {}
6743 }
6744 }
6745
6746 pub fn storage_ui_back(&mut self) {
6747 self.state.storage_ui_mode = match &self.state.storage_ui_mode {
6748 StorageUiMode::StoreAmount { pick_index, .. } => StorageUiMode::StorePick {
6749 index: *pick_index,
6750 },
6751 StorageUiMode::TakeAmount { pick_index, .. } => StorageUiMode::TakePick {
6752 index: *pick_index,
6753 },
6754 StorageUiMode::ShipAmount {
6755 dest_building_id,
6756 dest_label,
6757 pick_index,
6758 ..
6759 } => StorageUiMode::ShipPick {
6760 dest_building_id: dest_building_id.clone(),
6761 dest_label: dest_label.clone(),
6762 index: *pick_index,
6763 },
6764 StorageUiMode::StorePick { .. }
6765 | StorageUiMode::TakePick { .. }
6766 | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
6767 StorageUiMode::Menu => StorageUiMode::Menu,
6768 };
6769 }
6770
6771 pub fn storage_amount_append_char(&mut self, c: char) {
6772 match &mut self.state.storage_ui_mode {
6773 StorageUiMode::StoreAmount { input, .. }
6774 | StorageUiMode::TakeAmount { input, .. }
6775 | StorageUiMode::ShipAmount { input, .. } => {
6776 if c.is_ascii_digit() && input.len() < 8 {
6777 input.push(c);
6778 }
6779 }
6780 _ => {}
6781 }
6782 }
6783
6784 pub fn storage_amount_backspace(&mut self) {
6785 match &mut self.state.storage_ui_mode {
6786 StorageUiMode::StoreAmount { input, .. }
6787 | StorageUiMode::TakeAmount { input, .. }
6788 | StorageUiMode::ShipAmount { input, .. } => {
6789 input.pop();
6790 }
6791 _ => {}
6792 }
6793 }
6794
6795 pub fn storage_ui_typing(&self) -> bool {
6796 matches!(
6797 self.state.storage_ui_mode,
6798 StorageUiMode::StoreAmount { .. }
6799 | StorageUiMode::TakeAmount { .. }
6800 | StorageUiMode::ShipAmount { .. }
6801 )
6802 }
6803
6804 pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
6805 match self.state.storage_ui_mode.clone() {
6806 StorageUiMode::Menu => {
6807 let index = self.state.storage_menu_index;
6808 match index {
6809 0 => {
6810 let opts = self.state.storage_store_options();
6811 if opts.is_empty() {
6812 self.state.push_log("Nothing loose to store.");
6813 return Ok(());
6814 }
6815 self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
6816 }
6817 1 => {
6818 let opts = self.state.storage_vault_options();
6819 if opts.is_empty() {
6820 self.state.push_log("Vault is empty.");
6821 return Ok(());
6822 }
6823 self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
6824 }
6825 n => {
6826 let dest = self
6827 .state
6828 .storage_panel
6829 .as_ref()
6830 .and_then(|p| p.ship_destinations.get(n - 2))
6831 .cloned();
6832 let Some(dest) = dest else {
6833 return Ok(());
6834 };
6835 let opts = self.state.storage_vault_options();
6836 if opts.is_empty() {
6837 self.state
6838 .push_log("Vault is empty — nothing to ship.");
6839 return Ok(());
6840 }
6841 self.state.storage_ui_mode = StorageUiMode::ShipPick {
6842 dest_building_id: dest.building_id,
6843 dest_label: dest.label,
6844 index: 0,
6845 };
6846 }
6847 }
6848 }
6849 StorageUiMode::StorePick { index } => {
6850 let opts = self.state.storage_store_options();
6851 let Some(opt) = opts.get(index) else {
6852 self.state.push_log("Nothing loose to store.");
6853 self.state.storage_ui_mode = StorageUiMode::Menu;
6854 return Ok(());
6855 };
6856 self.state.storage_ui_mode = StorageUiMode::StoreAmount {
6857 pick_index: index,
6858 item_instance_id: opt.item_instance_id,
6859 label: opt.label.clone(),
6860 max_qty: opt.quantity.max(1),
6861 input: String::new(),
6862 };
6863 }
6864 StorageUiMode::TakePick { index } => {
6865 let opts = self.state.storage_vault_options();
6866 let Some(opt) = opts.get(index) else {
6867 self.state.push_log("Vault is empty.");
6868 self.state.storage_ui_mode = StorageUiMode::Menu;
6869 return Ok(());
6870 };
6871 self.state.storage_ui_mode = StorageUiMode::TakeAmount {
6872 pick_index: index,
6873 item_instance_id: opt.item_instance_id,
6874 label: opt.label.clone(),
6875 max_qty: opt.quantity.max(1),
6876 input: String::new(),
6877 };
6878 }
6879 StorageUiMode::ShipPick {
6880 dest_building_id,
6881 dest_label,
6882 index,
6883 } => {
6884 let opts = self.state.storage_vault_options();
6885 let Some(opt) = opts.get(index) else {
6886 self.state
6887 .push_log("Vault is empty — nothing to ship.");
6888 self.state.storage_ui_mode = StorageUiMode::Menu;
6889 return Ok(());
6890 };
6891 self.state.storage_ui_mode = StorageUiMode::ShipAmount {
6892 dest_building_id,
6893 dest_label,
6894 pick_index: index,
6895 item_instance_id: opt.item_instance_id,
6896 label: opt.label.clone(),
6897 max_qty: opt.quantity.max(1),
6898 input: String::new(),
6899 };
6900 }
6901 StorageUiMode::StoreAmount {
6902 item_instance_id,
6903 max_qty,
6904 input,
6905 ..
6906 } => {
6907 let Some(qty) = parse_storage_quantity(&input) else {
6908 self.state
6909 .push_log("Enter a quantity (blank or 0 = all).");
6910 return Ok(());
6911 };
6912 let qty = qty.map(|n| n.min(max_qty).max(1));
6913 self.storage_store(item_instance_id, qty).await?;
6914 self.state.storage_ui_mode = StorageUiMode::Menu;
6915 }
6916 StorageUiMode::TakeAmount {
6917 item_instance_id,
6918 max_qty,
6919 input,
6920 ..
6921 } => {
6922 let Some(qty) = parse_storage_quantity(&input) else {
6923 self.state
6924 .push_log("Enter a quantity (blank or 0 = all).");
6925 return Ok(());
6926 };
6927 let qty = qty.map(|n| n.min(max_qty).max(1));
6928 self.storage_take(item_instance_id, qty).await?;
6929 self.state.storage_ui_mode = StorageUiMode::Menu;
6930 }
6931 StorageUiMode::ShipAmount {
6932 dest_building_id,
6933 item_instance_id,
6934 max_qty,
6935 input,
6936 ..
6937 } => {
6938 let Some(qty) = parse_storage_quantity(&input) else {
6939 self.state
6940 .push_log("Enter a quantity (blank or 0 = all).");
6941 return Ok(());
6942 };
6943 let qty = qty.map(|n| n.min(max_qty).max(1));
6944 self.storage_ship(dest_building_id, item_instance_id, qty)
6945 .await?;
6946 self.state.storage_ui_mode = StorageUiMode::Menu;
6947 }
6948 }
6949 Ok(())
6950 }
6951
6952 pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
6953 match self.state.bank_ui_mode.clone() {
6954 BankUiMode::Menu => {
6955 let choice = self
6956 .state
6957 .bank_menu_options()
6958 .get(self.state.bank_menu_index)
6959 .copied()
6960 .unwrap_or("Deposit…");
6961 match choice {
6962 "Withdraw…" => {
6963 self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
6964 input: String::new(),
6965 };
6966 }
6967 "Deposit all" => self.bank_deposit(0).await?,
6968 "Withdraw all" => self.bank_withdraw(0).await?,
6969 "Transfer…" => {
6970 self.state.bank_ui_mode = BankUiMode::TransferName {
6971 input: String::new(),
6972 };
6973 }
6974 _ => {
6975 self.state.bank_ui_mode = BankUiMode::DepositAmount {
6976 input: String::new(),
6977 };
6978 }
6979 }
6980 }
6981 BankUiMode::DepositAmount { input } => {
6982 let Some(amount) = parse_bank_copper_amount(&input) else {
6983 self.state
6984 .push_log("Enter a copper amount (blank or 0 = everything on person).");
6985 return Ok(());
6986 };
6987 self.bank_deposit(amount).await?;
6988 self.state.bank_ui_mode = BankUiMode::Menu;
6989 }
6990 BankUiMode::WithdrawAmount { input } => {
6991 let Some(amount) = parse_bank_copper_amount(&input) else {
6992 self.state
6993 .push_log("Enter a copper amount (blank or 0 = full ledger).");
6994 return Ok(());
6995 };
6996 self.bank_withdraw(amount).await?;
6997 self.state.bank_ui_mode = BankUiMode::Menu;
6998 }
6999 BankUiMode::TransferName { input } => {
7000 let name = input.trim().to_string();
7001 if name.is_empty() {
7002 self.state.push_log("Enter the recipient character name.");
7003 return Ok(());
7004 }
7005 self.state.bank_ui_mode = BankUiMode::TransferAmount {
7006 to_name: name,
7007 input: String::new(),
7008 };
7009 }
7010 BankUiMode::TransferAmount { to_name, input } => {
7011 let amount: u64 = match input.trim().parse() {
7012 Ok(v) if v > 0 => v,
7013 _ => {
7014 self.state
7015 .push_log("Enter a positive copper amount to transfer.");
7016 return Ok(());
7017 }
7018 };
7019 self.bank_transfer(None, to_name, amount).await?;
7020 self.state.bank_ui_mode = BankUiMode::Menu;
7021 }
7022 }
7023 Ok(())
7024 }
7025
7026 pub fn bank_transfer_back(&mut self) {
7027 match &self.state.bank_ui_mode {
7028 BankUiMode::TransferAmount { to_name, .. } => {
7029 self.state.bank_ui_mode = BankUiMode::TransferName {
7030 input: to_name.clone(),
7031 };
7032 }
7033 BankUiMode::TransferName { .. }
7034 | BankUiMode::DepositAmount { .. }
7035 | BankUiMode::WithdrawAmount { .. } => {
7036 self.state.bank_ui_mode = BankUiMode::Menu;
7037 }
7038 BankUiMode::Menu => {}
7039 }
7040 }
7041
7042 pub fn bank_transfer_append_char(&mut self, c: char) {
7043 match &mut self.state.bank_ui_mode {
7044 BankUiMode::TransferName { input } => {
7045 if input.len() < 32 && !c.is_control() {
7046 input.push(c);
7047 }
7048 }
7049 BankUiMode::DepositAmount { input }
7050 | BankUiMode::WithdrawAmount { input }
7051 | BankUiMode::TransferAmount { input, .. } => {
7052 if c.is_ascii_digit() && input.len() < 12 {
7053 input.push(c);
7054 }
7055 }
7056 BankUiMode::Menu => {}
7057 }
7058 }
7059
7060 pub fn bank_transfer_backspace(&mut self) {
7061 match &mut self.state.bank_ui_mode {
7062 BankUiMode::TransferName { input }
7063 | BankUiMode::DepositAmount { input }
7064 | BankUiMode::WithdrawAmount { input }
7065 | BankUiMode::TransferAmount { input, .. } => {
7066 input.pop();
7067 }
7068 BankUiMode::Menu => {}
7069 }
7070 }
7071
7072 pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
7073 let npc_id = self
7074 .state
7075 .bank_panel
7076 .as_ref()
7077 .map(|p| p.npc_id.clone());
7078 self.state.clear_bank_panel();
7079 if let Some(npc_id) = npc_id {
7080 self.seq += 1;
7081 self.session
7082 .submit_intent(Intent::BankClose {
7083 entity_id: self.state.entity_id,
7084 npc_id,
7085 seq: self.seq,
7086 })
7087 .await?;
7088 self.state.intents_sent += 1;
7089 }
7090 Ok(())
7091 }
7092
7093 pub async fn storage_store(
7094 &mut self,
7095 item_instance_id: uuid::Uuid,
7096 quantity: Option<u32>,
7097 ) -> anyhow::Result<()> {
7098 let Some(panel) = self.state.storage_panel.clone() else {
7099 return Ok(());
7100 };
7101 self.seq += 1;
7102 self.session
7103 .submit_intent(Intent::StorageStore {
7104 entity_id: self.state.entity_id,
7105 npc_id: panel.npc_id,
7106 item_instance_id,
7107 quantity,
7108 seq: self.seq,
7109 })
7110 .await?;
7111 self.state.intents_sent += 1;
7112 Ok(())
7113 }
7114
7115 pub async fn storage_take(
7116 &mut self,
7117 item_instance_id: uuid::Uuid,
7118 quantity: Option<u32>,
7119 ) -> anyhow::Result<()> {
7120 let Some(panel) = self.state.storage_panel.clone() else {
7121 return Ok(());
7122 };
7123 self.seq += 1;
7124 self.session
7125 .submit_intent(Intent::StorageTake {
7126 entity_id: self.state.entity_id,
7127 npc_id: panel.npc_id,
7128 item_instance_id,
7129 quantity,
7130 seq: self.seq,
7131 })
7132 .await?;
7133 self.state.intents_sent += 1;
7134 Ok(())
7135 }
7136
7137 pub async fn storage_ship(
7138 &mut self,
7139 dest_building_id: String,
7140 item_instance_id: uuid::Uuid,
7141 quantity: Option<u32>,
7142 ) -> anyhow::Result<()> {
7143 let Some(panel) = self.state.storage_panel.clone() else {
7144 return Ok(());
7145 };
7146 self.seq += 1;
7147 self.session
7148 .submit_intent(Intent::StorageShip {
7149 entity_id: self.state.entity_id,
7150 npc_id: panel.npc_id,
7151 dest_building_id,
7152 item_instance_id,
7153 quantity,
7154 seq: self.seq,
7155 })
7156 .await?;
7157 self.state.intents_sent += 1;
7158 Ok(())
7159 }
7160
7161 pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
7162 let npc_id = self
7163 .state
7164 .storage_panel
7165 .as_ref()
7166 .map(|p| p.npc_id.clone());
7167 self.state.clear_storage_panel();
7168 if let Some(npc_id) = npc_id {
7169 self.seq += 1;
7170 self.session
7171 .submit_intent(Intent::StorageClose {
7172 entity_id: self.state.entity_id,
7173 npc_id,
7174 seq: self.seq,
7175 })
7176 .await?;
7177 self.state.intents_sent += 1;
7178 }
7179 Ok(())
7180 }
7181
7182 pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
7184 let return_to_verbs = self.state.npc_verb_target.is_some();
7185 self.close_shop_menu().await?;
7186 if return_to_verbs {
7187 self.state.show_npc_verb_menu = true;
7188 }
7189 Ok(())
7190 }
7191
7192 pub fn shop_tab_toggle(&mut self) {
7193 self.state.shop_tab = match self.state.shop_tab {
7194 ShopTab::Buy => ShopTab::Sell,
7195 ShopTab::Sell => ShopTab::Buy,
7196 };
7197 self.state.shop_menu_index = 0;
7198 if self.state.shop_tab == ShopTab::Sell {
7199 self.state.shop_quantity_set_max();
7200 }
7201 self.state.clamp_shop_selection();
7202 }
7203
7204 pub fn shop_menu_move(&mut self, delta: i32) {
7205 self.state.shop_menu_move(delta);
7206 }
7207
7208 pub fn shop_quantity_adjust(&mut self, delta: i32) {
7209 self.state.shop_quantity_adjust(delta);
7210 }
7211
7212 pub fn shop_quantity_set_max(&mut self) {
7213 self.state.shop_quantity_set_max();
7214 }
7215
7216 pub fn toggle_quest_menu(&mut self) {
7217 self.state.show_quest_menu = !self.state.show_quest_menu;
7218 if self.state.show_quest_menu {
7219 self.state.quest_menu_index = 0;
7220 self.state.quest_withdraw_confirm = false;
7221 self.state.show_workers_menu = false;
7222 }
7223 }
7224
7225 pub fn toggle_workers_menu(&mut self) {
7226 self.state.show_workers_menu = !self.state.show_workers_menu;
7227 if self.state.show_workers_menu {
7228 self.state.workers_menu_index = 0;
7229 self.state.show_quest_menu = false;
7230 self.close_worker_give_picker();
7231 self.close_worker_give_target_picker();
7232 self.close_worker_take_picker();
7233 self.close_worker_teach_picker();
7234 self.cancel_worker_rename();
7235 } else {
7236 self.close_worker_give_picker();
7237 self.close_worker_give_target_picker();
7238 self.close_worker_take_picker();
7239 self.close_worker_teach_picker();
7240 self.cancel_worker_rename();
7241 }
7242 }
7243
7244 pub fn workers_menu_move(&mut self, delta: i32) {
7245 let n = self.state.hired_workers.len();
7246 if n == 0 {
7247 return;
7248 }
7249 let idx = self.state.workers_menu_index as i32;
7250 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
7251 }
7252
7253 pub fn toggle_workers_menu_compact(&mut self) {
7254 self.state.workers_menu_compact = !self.state.workers_menu_compact;
7255 let mut cfg = crate::client_config::ClientConfig::load();
7256 let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
7257 }
7258
7259 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
7260 let Some(worker) = self
7261 .state
7262 .hired_workers
7263 .get(self.state.workers_menu_index)
7264 .cloned()
7265 else {
7266 anyhow::bail!("no worker selected");
7267 };
7268 self.seq += 1;
7269 self.session
7270 .submit_intent(Intent::DismissWorker {
7271 entity_id: self.state.entity_id,
7272 worker_instance_id: worker.instance_id.clone(),
7273 seq: self.seq,
7274 })
7275 .await?;
7276 self.state.intents_sent += 1;
7277 self.state
7278 .hired_workers
7279 .retain(|w| w.instance_id != worker.instance_id);
7280 if self.state.workers_menu_index >= self.state.hired_workers.len() {
7281 self.state.workers_menu_index = self
7282 .state
7283 .hired_workers
7284 .len()
7285 .saturating_sub(1);
7286 }
7287 self.state.push_log(format!("Dismissed {}", worker.label));
7288 Ok(())
7289 }
7290
7291 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
7292 let Some(worker) = self
7293 .state
7294 .hired_workers
7295 .get(self.state.workers_menu_index)
7296 .cloned()
7297 else {
7298 anyhow::bail!("no worker selected");
7299 };
7300 let mode = match worker.mode {
7301 flatland_protocol::WorkerModeView::Companion => "job_loop",
7302 flatland_protocol::WorkerModeView::JobLoop => "idle",
7303 flatland_protocol::WorkerModeView::Idle => "companion",
7304 };
7305 self.seq += 1;
7306 self.session
7307 .submit_intent(Intent::SetWorkerMode {
7308 entity_id: self.state.entity_id,
7309 worker_instance_id: worker.instance_id,
7310 mode: mode.into(),
7311 seq: self.seq,
7312 })
7313 .await?;
7314 self.state.intents_sent += 1;
7315 Ok(())
7316 }
7317
7318 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
7319 if self.state.hired_workers.is_empty() {
7320 return self.hire_worker_laborer().await;
7321 }
7322 self.workers_toggle_mode_selected().await
7323 }
7324
7325 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
7328 let row = self
7329 .state
7330 .inventory_selected_row()
7331 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
7332 .clone();
7333 if row.from != flatland_protocol::InventoryLocation::Root {
7334 anyhow::bail!("select a carried item to give");
7335 }
7336 let Some(instance_id) = row.stack.item_instance_id else {
7337 anyhow::bail!("that stack can't be given");
7338 };
7339 let options = self.nearby_worker_give_targets();
7340 if options.is_empty() {
7341 anyhow::bail!(
7342 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
7343 );
7344 }
7345 let item_label = row
7346 .stack
7347 .display_name
7348 .as_deref()
7349 .unwrap_or(&row.stack.template_id)
7350 .to_string();
7351 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
7352 item_instance_id: instance_id,
7353 item_label,
7354 quantity: None,
7355 options,
7356 });
7357 self.state.worker_give_target_picker_index = 0;
7358 self.state.show_worker_give_target_picker = true;
7359 self.state.show_inventory_menu = false;
7361 Ok(())
7362 }
7363
7364 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
7366 let (px, py, _) = self.state.player_position_with_z();
7367 let mut options: Vec<WorkerGiveTargetOption> = self
7368 .state
7369 .hired_workers
7370 .iter()
7371 .filter_map(|w| {
7372 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
7373 if dist > WORKER_GIVE_RANGE_M {
7374 return None;
7375 }
7376 Some(WorkerGiveTargetOption {
7377 instance_id: w.instance_id.clone(),
7378 label: w.label.clone(),
7379 distance_m: dist,
7380 })
7381 })
7382 .collect();
7383 options.sort_by(|a, b| {
7384 a.distance_m
7385 .partial_cmp(&b.distance_m)
7386 .unwrap_or(std::cmp::Ordering::Equal)
7387 });
7388 options
7389 }
7390
7391 pub fn close_worker_give_target_picker(&mut self) {
7392 self.state.show_worker_give_target_picker = false;
7393 self.state.worker_give_target_picker = None;
7394 self.state.worker_give_target_picker_index = 0;
7395 }
7396
7397 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
7398 let Some(picker) = &self.state.worker_give_target_picker else {
7399 return;
7400 };
7401 let n = picker.options.len();
7402 if n == 0 {
7403 return;
7404 }
7405 let idx = self.state.worker_give_target_picker_index as i32;
7406 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7407 }
7408
7409 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
7410 let Some(picker) = self.state.worker_give_target_picker.clone() else {
7411 anyhow::bail!("give target picker not open");
7412 };
7413 let Some(opt) = picker
7414 .options
7415 .get(self.state.worker_give_target_picker_index)
7416 .cloned()
7417 else {
7418 anyhow::bail!("no worker selected");
7419 };
7420 let Some(worker) = self
7421 .state
7422 .hired_workers
7423 .iter()
7424 .find(|w| w.instance_id == opt.instance_id)
7425 .cloned()
7426 else {
7427 self.close_worker_give_target_picker();
7428 anyhow::bail!("worker no longer hired");
7429 };
7430 self.give_item_to_worker(
7431 &worker.instance_id,
7432 &worker.label,
7433 worker.x,
7434 worker.y,
7435 picker.item_instance_id,
7436 &picker.item_label,
7437 picker.quantity,
7438 )
7439 .await?;
7440 self.close_worker_give_target_picker();
7441 Ok(())
7442 }
7443
7444 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
7446 self.open_worker_give_target_picker()
7447 }
7448
7449 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
7451 let Some(worker) = self
7452 .state
7453 .hired_workers
7454 .get(self.state.workers_menu_index)
7455 .cloned()
7456 else {
7457 anyhow::bail!("select a hired worker first");
7458 };
7459 let (px, py, _) = self.state.player_position_with_z();
7460 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7461 if dist > WORKER_GIVE_RANGE_M {
7462 anyhow::bail!(
7463 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
7464 worker.label
7465 );
7466 }
7467 let options = self.state.giveable_inventory_options();
7468 if options.is_empty() {
7469 anyhow::bail!("nothing in inventory to give");
7470 }
7471 self.state.worker_give_picker = Some(WorkerGivePicker {
7472 worker_instance_id: worker.instance_id,
7473 worker_label: worker.label,
7474 options,
7475 });
7476 self.state.worker_give_picker_index = 0;
7477 self.state.show_worker_give_picker = true;
7478 Ok(())
7479 }
7480
7481 pub fn close_worker_give_picker(&mut self) {
7482 self.state.show_worker_give_picker = false;
7483 self.state.worker_give_picker = None;
7484 self.state.worker_give_picker_index = 0;
7485 }
7486
7487 pub fn worker_give_picker_move(&mut self, delta: i32) {
7488 let Some(picker) = &self.state.worker_give_picker else {
7489 return;
7490 };
7491 let n = picker.options.len();
7492 if n == 0 {
7493 return;
7494 }
7495 let idx = self.state.worker_give_picker_index as i32;
7496 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7497 }
7498
7499 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
7501 let Some(picker) = self.state.worker_give_picker.clone() else {
7502 anyhow::bail!("give picker not open");
7503 };
7504 let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
7505 anyhow::bail!("no item selected");
7506 };
7507 let Some(worker) = self
7508 .state
7509 .hired_workers
7510 .iter()
7511 .find(|w| w.instance_id == picker.worker_instance_id)
7512 .cloned()
7513 else {
7514 self.close_worker_give_picker();
7515 anyhow::bail!("worker no longer hired");
7516 };
7517 self.give_item_to_worker(
7518 &worker.instance_id,
7519 &worker.label,
7520 worker.x,
7521 worker.y,
7522 opt.item_instance_id,
7523 &opt.label,
7524 None,
7525 )
7526 .await?;
7527 let options = self.state.giveable_inventory_options();
7529 if options.is_empty() {
7530 self.close_worker_give_picker();
7531 } else {
7532 self.state.worker_give_picker = Some(WorkerGivePicker {
7533 worker_instance_id: picker.worker_instance_id,
7534 worker_label: picker.worker_label,
7535 options,
7536 });
7537 if self.state.worker_give_picker_index
7538 >= self
7539 .state
7540 .worker_give_picker
7541 .as_ref()
7542 .map(|p| p.options.len())
7543 .unwrap_or(0)
7544 {
7545 self.state.worker_give_picker_index = self
7546 .state
7547 .worker_give_picker
7548 .as_ref()
7549 .map(|p| p.options.len().saturating_sub(1))
7550 .unwrap_or(0);
7551 }
7552 }
7553 Ok(())
7554 }
7555
7556 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
7558 let Some(worker) = self
7559 .state
7560 .hired_workers
7561 .get(self.state.workers_menu_index)
7562 .cloned()
7563 else {
7564 anyhow::bail!("select a hired worker first");
7565 };
7566 let (px, py, _) = self.state.player_position_with_z();
7567 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7568 if dist > WORKER_GIVE_RANGE_M {
7569 anyhow::bail!(
7570 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
7571 worker.label
7572 );
7573 }
7574 let options = self.state.teachable_blueprint_options(&worker);
7575 if options.is_empty() {
7576 anyhow::bail!("no recipes you know that {} still needs", worker.label);
7577 }
7578 self.state.worker_teach_picker = Some(WorkerTeachPicker {
7579 worker_instance_id: worker.instance_id,
7580 worker_label: worker.label,
7581 worker_level: worker.level,
7582 options,
7583 });
7584 self.state.worker_teach_picker_index = 0;
7585 self.state.show_worker_teach_picker = true;
7586 Ok(())
7587 }
7588
7589 pub fn close_worker_teach_picker(&mut self) {
7590 self.state.show_worker_teach_picker = false;
7591 self.state.worker_teach_picker = None;
7592 self.state.worker_teach_picker_index = 0;
7593 }
7594
7595 pub fn worker_teach_picker_move(&mut self, delta: i32) {
7596 let Some(picker) = &self.state.worker_teach_picker else {
7597 return;
7598 };
7599 let n = picker.options.len();
7600 if n == 0 {
7601 return;
7602 }
7603 let idx = self.state.worker_teach_picker_index as i32;
7604 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7605 }
7606
7607 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
7608 let Some(picker) = self.state.worker_teach_picker.clone() else {
7609 anyhow::bail!("teach picker not open");
7610 };
7611 let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
7612 anyhow::bail!("nothing selected");
7613 };
7614 if !opt.level_ok {
7615 anyhow::bail!(
7616 "{} needs level {} (is level {})",
7617 picker.worker_label,
7618 opt.min_level,
7619 opt.worker_level
7620 );
7621 }
7622 if !opt.can_afford {
7623 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
7624 }
7625 let Some(worker) = self
7626 .state
7627 .hired_workers
7628 .iter()
7629 .find(|w| w.instance_id == picker.worker_instance_id)
7630 .cloned()
7631 else {
7632 anyhow::bail!("worker gone");
7633 };
7634 let (px, py, _) = self.state.player_position_with_z();
7635 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7636 if dist > WORKER_GIVE_RANGE_M {
7637 anyhow::bail!("worker {} too far — stand next to them", worker.label);
7638 }
7639 self.seq += 1;
7640 self.session
7641 .submit_intent(Intent::TeachWorkerBlueprint {
7642 entity_id: self.state.entity_id,
7643 worker_instance_id: picker.worker_instance_id.clone(),
7644 blueprint_id: opt.blueprint_id.clone(),
7645 seq: self.seq,
7646 })
7647 .await?;
7648 self.state.intents_sent += 1;
7649 self.state.push_log(format!(
7650 "Teaching {} to {} ({} cp)",
7651 opt.label, picker.worker_label, opt.cost_copper
7652 ));
7653 self.close_worker_teach_picker();
7654 Ok(())
7655 }
7656
7657 async fn give_item_to_worker(
7658 &mut self,
7659 worker_instance_id: &str,
7660 worker_label: &str,
7661 worker_x: f32,
7662 worker_y: f32,
7663 item_instance_id: uuid::Uuid,
7664 item_label: &str,
7665 quantity: Option<u32>,
7666 ) -> anyhow::Result<()> {
7667 let (px, py, _) = self.state.player_position_with_z();
7668 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
7669 if dist > WORKER_GIVE_RANGE_M {
7670 anyhow::bail!("worker {worker_label} too far — stand next to them");
7671 }
7672 self.seq += 1;
7673 self.session
7674 .submit_intent(Intent::GiveWorkerItem {
7675 entity_id: self.state.entity_id,
7676 worker_instance_id: worker_instance_id.to_string(),
7677 item_instance_id,
7678 quantity,
7679 seq: self.seq,
7680 })
7681 .await?;
7682 self.state.intents_sent += 1;
7683 self.state
7684 .push_log(format!("Gave {item_label} to {worker_label}"));
7685 Ok(())
7686 }
7687
7688 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
7690 let Some(worker) = self
7691 .state
7692 .hired_workers
7693 .get(self.state.workers_menu_index)
7694 .cloned()
7695 else {
7696 anyhow::bail!("select a hired worker first");
7697 };
7698 let (px, py, _) = self.state.player_position_with_z();
7699 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7700 if dist > WORKER_GIVE_RANGE_M {
7701 anyhow::bail!(
7702 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
7703 worker.label
7704 );
7705 }
7706 let options = Self::worker_inventory_options(&worker);
7707 if options.is_empty() {
7708 anyhow::bail!("{} isn't carrying anything", worker.label);
7709 }
7710 self.state.worker_take_picker = Some(WorkerTakePicker {
7711 worker_instance_id: worker.instance_id,
7712 worker_label: worker.label,
7713 options,
7714 });
7715 self.state.worker_take_picker_index = 0;
7716 self.state.show_worker_take_picker = true;
7717 Ok(())
7718 }
7719
7720 fn worker_inventory_options(
7721 worker: &flatland_protocol::HiredWorkerView,
7722 ) -> Vec<WorkerGiveOption> {
7723 worker
7724 .inventory
7725 .iter()
7726 .filter_map(|stack| {
7727 let item_instance_id = stack.item_instance_id?;
7728 let label = stack
7729 .display_name
7730 .clone()
7731 .unwrap_or_else(|| stack.template_id.clone());
7732 let label = if stack.quantity > 1 {
7733 format!("{label} ×{}", stack.quantity)
7734 } else {
7735 label
7736 };
7737 Some(WorkerGiveOption {
7738 item_instance_id,
7739 label,
7740 quantity: stack.quantity,
7741 template_id: stack.template_id.clone(),
7742 })
7743 })
7744 .collect()
7745 }
7746
7747 pub fn close_worker_take_picker(&mut self) {
7748 self.state.show_worker_take_picker = false;
7749 self.state.worker_take_picker = None;
7750 self.state.worker_take_picker_index = 0;
7751 }
7752
7753 pub fn worker_take_picker_move(&mut self, delta: i32) {
7754 let Some(picker) = &self.state.worker_take_picker else {
7755 return;
7756 };
7757 let n = picker.options.len();
7758 if n == 0 {
7759 return;
7760 }
7761 let idx = self.state.worker_take_picker_index as i32;
7762 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7763 }
7764
7765 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
7766 let Some(picker) = self.state.worker_take_picker.clone() else {
7767 anyhow::bail!("take picker not open");
7768 };
7769 let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
7770 anyhow::bail!("no item selected");
7771 };
7772 let Some(worker) = self
7773 .state
7774 .hired_workers
7775 .iter()
7776 .find(|w| w.instance_id == picker.worker_instance_id)
7777 .cloned()
7778 else {
7779 self.close_worker_take_picker();
7780 anyhow::bail!("worker no longer hired");
7781 };
7782 self.take_item_from_worker(
7783 &worker.instance_id,
7784 &worker.label,
7785 worker.x,
7786 worker.y,
7787 opt.item_instance_id,
7788 &opt.label,
7789 None,
7790 )
7791 .await?;
7792 if let Some(w) = self
7796 .state
7797 .hired_workers
7798 .iter()
7799 .find(|w| w.instance_id == picker.worker_instance_id)
7800 {
7801 let options = Self::worker_inventory_options(w)
7802 .into_iter()
7803 .filter(|o| o.item_instance_id != opt.item_instance_id)
7804 .collect::<Vec<_>>();
7805 if options.is_empty() {
7806 self.close_worker_take_picker();
7807 } else {
7808 self.state.worker_take_picker = Some(WorkerTakePicker {
7809 worker_instance_id: picker.worker_instance_id,
7810 worker_label: picker.worker_label,
7811 options,
7812 });
7813 self.state.worker_take_picker_index = self
7814 .state
7815 .worker_take_picker_index
7816 .min(
7817 self.state
7818 .worker_take_picker
7819 .as_ref()
7820 .map(|p| p.options.len().saturating_sub(1))
7821 .unwrap_or(0),
7822 );
7823 }
7824 } else {
7825 self.close_worker_take_picker();
7826 }
7827 Ok(())
7828 }
7829
7830 async fn take_item_from_worker(
7831 &mut self,
7832 worker_instance_id: &str,
7833 worker_label: &str,
7834 worker_x: f32,
7835 worker_y: f32,
7836 item_instance_id: uuid::Uuid,
7837 item_label: &str,
7838 quantity: Option<u32>,
7839 ) -> anyhow::Result<()> {
7840 let (px, py, _) = self.state.player_position_with_z();
7841 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
7842 if dist > WORKER_GIVE_RANGE_M {
7843 anyhow::bail!("worker {worker_label} too far — stand next to them");
7844 }
7845 self.seq += 1;
7846 self.session
7847 .submit_intent(Intent::TakeWorkerItem {
7848 entity_id: self.state.entity_id,
7849 worker_instance_id: worker_instance_id.to_string(),
7850 item_instance_id,
7851 quantity,
7852 seq: self.seq,
7853 })
7854 .await?;
7855 self.state.intents_sent += 1;
7856 self.state
7857 .push_log(format!("Took {item_label} from {worker_label}"));
7858 Ok(())
7859 }
7860
7861 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
7862 if !self.state.has_worker_lodging() {
7863 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
7864 }
7865 self.seq += 1;
7866 self.session
7867 .submit_intent(Intent::HireWorker {
7868 entity_id: self.state.entity_id,
7869 def_id: "worker_laborer".into(),
7870 wage_copper_per_interval: 8,
7871 lodging_container_id: None,
7872 job_yaml: None,
7873 seq: self.seq,
7874 })
7875 .await?;
7876 self.state.intents_sent += 1;
7877 Ok(())
7878 }
7879
7880 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
7881 let Some(worker) = self
7882 .state
7883 .hired_workers
7884 .get(self.state.workers_menu_index)
7885 .cloned()
7886 else {
7887 anyhow::bail!("select a hired worker first");
7888 };
7889 let lodging = worker.lodging_container_id.clone().or_else(|| {
7890 crate::worker_route_editor::owned_lodging_container_ids(
7891 &self.state.placed_containers,
7892 self.state.character_id,
7893 )
7894 .into_iter()
7895 .next()
7896 .map(|(id, _)| id)
7897 });
7898 let label = worker.label.clone();
7899 let editor = if let Some(route) = &worker.route {
7900 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
7901 worker.instance_id,
7902 worker.label,
7903 route,
7904 lodging,
7905 )
7906 } else {
7907 crate::worker_route_editor::WorkerRouteEditorState::new(
7908 worker.instance_id,
7909 worker.label,
7910 lodging,
7911 )
7912 };
7913 self.state.worker_route_editor = Some(editor);
7914 if let Some(ed) = self.state.worker_route_editor.as_mut() {
7915 if let Some(collapsed) =
7916 crate::client_config::ClientConfig::load().worker_route_panel_collapsed
7917 {
7918 ed.panel_collapsed = collapsed;
7919 }
7920 }
7921 self.state.show_workers_menu = false;
7922 self.state.push_log(format!(
7923 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
7924 ));
7925 Ok(())
7926 }
7927
7928 pub fn close_worker_route_editor(&mut self) {
7929 self.state.worker_route_editor = None;
7930 }
7931
7932 pub fn worker_route_editor_toggle_panel(&mut self) {
7933 if let Some(ed) = self.state.worker_route_editor.as_mut() {
7934 ed.toggle_panel_collapsed();
7935 let collapsed = ed.panel_collapsed;
7936 let mut cfg = crate::client_config::ClientConfig::load();
7937 let _ = cfg.save_worker_route_panel_collapsed(collapsed);
7938 }
7939 }
7940
7941 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
7942 let n = {
7943 let Some(ed) = self.state.worker_route_editor.as_mut() else {
7944 return;
7945 };
7946 ed.append_waypoint(x, y, z);
7947 ed.stop_count()
7948 };
7949 self.state
7950 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
7951 }
7952
7953 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
7956 let (px, py, _) = self.state.player_position_with_z();
7957 crate::worker_route_editor::owned_container_candidates(
7958 &self.state.placed_containers,
7959 self.state.character_id,
7960 px,
7961 py,
7962 )
7963 }
7964
7965 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
7966 let (px, py, _) = self.state.player_position_with_z();
7967 crate::worker_route_editor::node_candidates(&self.state.resource_nodes, px, py)
7968 }
7969
7970 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
7971 let (px, py, _) = self.state.player_position_with_z();
7972 crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
7973 }
7974
7975 fn re_template_candidates(&self) -> Vec<String> {
7976 let mut extra = Vec::new();
7977 if let Some(ed) = self.state.worker_route_editor.as_ref() {
7978 for stop in &ed.stops {
7979 match stop {
7980 crate::worker_route_editor::WorkerRouteStop::DepositAt {
7981 filter: Some(filter),
7982 ..
7983 } => extra.extend(filter.iter().cloned()),
7984 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
7985 extra.push(template.clone());
7986 }
7987 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
7988 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
7989 extra.push(bp.output.clone());
7990 for input in &bp.inputs {
7991 extra.push(input.template_id.clone());
7992 }
7993 }
7994 }
7995 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
7996 for it in items {
7997 extra.push(it.template.clone());
7998 }
7999 }
8000 _ => {}
8001 }
8002 }
8003 if let Some(worker) = self
8005 .state
8006 .hired_workers
8007 .iter()
8008 .find(|w| w.instance_id == ed.worker_instance_id)
8009 {
8010 for recipe in &worker.known_blueprint_ids {
8011 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
8012 extra.push(bp.output.clone());
8013 }
8014 }
8015 }
8016 }
8017 crate::worker_route_editor::route_item_template_candidates(
8018 &self.state.placed_containers,
8019 self.state.character_id,
8020 &self.state.inventory,
8021 &self.state.blueprints,
8022 &self.state.resource_nodes,
8023 &extra,
8024 )
8025 }
8026
8027 fn re_blueprint_ids(&self) -> Vec<String> {
8028 let worker_known: Option<&[String]> = self
8029 .state
8030 .worker_route_editor
8031 .as_ref()
8032 .and_then(|ed| {
8033 self.state
8034 .hired_workers
8035 .iter()
8036 .find(|w| w.instance_id == ed.worker_instance_id)
8037 })
8038 .map(|w| w.known_blueprint_ids.as_slice());
8039 crate::worker_route_editor::worker_craft_blueprint_ids(
8040 &self.state.blueprints,
8041 worker_known,
8042 )
8043 }
8044
8045 fn re_bed_candidates(&self) -> Vec<(String, String)> {
8046 crate::worker_route_editor::owned_lodging_container_ids(
8047 &self.state.placed_containers,
8048 self.state.character_id,
8049 )
8050 }
8051
8052 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
8053 self.state
8054 .placed_containers
8055 .iter()
8056 .find(|c| c.id == container_id)
8057 .map(|c| c.contents.clone())
8058 .unwrap_or_default()
8059 }
8060
8061 pub fn re_sheet_row_count(&self) -> usize {
8065 use crate::worker_route_editor::RouteEditorSheet as S;
8066 let Some(ed) = self.state.worker_route_editor.as_ref() else {
8067 return 0;
8068 };
8069 match &ed.sheet {
8070 S::Stops => ed.stops.len(),
8071 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
8072 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
8073 S::WaypointMapPick => 0,
8074 S::HarvestPicker { .. } => self.re_node_candidates().len(),
8075 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
8076 self.re_container_candidates().len()
8077 }
8078 S::WithdrawItems { lines, .. } => lines.len() + 1, S::DepositFilter { rows, .. } => rows.len() + 1, S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, S::SellItem { templates, .. } => templates.len() + 1, S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
8083 S::WaitEntry { .. } => 1,
8084 S::BedPicker { .. } => self.re_bed_candidates().len(),
8085 }
8086 }
8087
8088 pub fn re_sheet_index(&self) -> usize {
8090 use crate::worker_route_editor::RouteEditorSheet as S;
8091 let Some(ed) = self.state.worker_route_editor.as_ref() else {
8092 return 0;
8093 };
8094 match &ed.sheet {
8095 S::AddMenu { index }
8096 | S::WaypointMenu { index }
8097 | S::HarvestPicker { index }
8098 | S::WithdrawContainers { index }
8099 | S::DepositContainers { index }
8100 | S::SellNpcs { index }
8101 | S::CraftBlueprint { index }
8102 | S::BedPicker { index }
8103 | S::WithdrawItems { index, .. }
8104 | S::DepositFilter { index, .. }
8105 | S::SellItem { index, .. } => *index,
8106 _ => 0,
8107 }
8108 }
8109
8110 pub fn re_sheet_move(&mut self, delta: i32) {
8112 use crate::worker_route_editor::RouteEditorSheet as S;
8113 let count = self.re_sheet_row_count();
8114 if count == 0 {
8115 return;
8116 }
8117 let cur = self.re_sheet_index() as i32;
8118 let next = (cur + delta).rem_euclid(count as i32) as usize;
8119 let Some(ed) = self.state.worker_route_editor.as_mut() else {
8120 return;
8121 };
8122 match &mut ed.sheet {
8123 S::AddMenu { index }
8124 | S::WaypointMenu { index }
8125 | S::HarvestPicker { index }
8126 | S::WithdrawContainers { index }
8127 | S::DepositContainers { index }
8128 | S::SellNpcs { index }
8129 | S::CraftBlueprint { index }
8130 | S::BedPicker { index }
8131 | S::WithdrawItems { index, .. }
8132 | S::DepositFilter { index, .. }
8133 | S::SellItem { index, .. } => *index = next,
8134 _ => {}
8135 }
8136 }
8137
8138 pub fn re_sheet_adjust(&mut self, delta: i32) {
8140 use crate::worker_route_editor::RouteEditorSheet as S;
8141 let index = self.re_sheet_index();
8142 let Some(ed) = self.state.worker_route_editor.as_mut() else {
8143 return;
8144 };
8145 match &mut ed.sheet {
8146 S::WithdrawItems { lines, .. } => {
8147 if let Some(line) = lines.get_mut(index) {
8148 line.adjust_qty(delta);
8149 }
8150 }
8151 S::WaitEntry { ticks } => {
8152 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
8153 }
8154 _ => {}
8155 }
8156 }
8157
8158 pub fn re_sheet_back(&mut self) {
8159 let Some(ed) = self.state.worker_route_editor.as_mut() else {
8160 return;
8161 };
8162 use crate::worker_route_editor::RouteEditorSheet as S;
8163 let was_editing = ed.editing_index.is_some();
8164 let from_top_picker = matches!(
8165 ed.sheet,
8166 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
8167 );
8168 ed.sheet_back();
8169 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
8170 self.state
8172 .push_log("Route: left edit sheet — press s to save current stops".to_string());
8173 }
8174 }
8175
8176 pub fn re_at_root_sheet(&self) -> bool {
8178 self.state
8179 .worker_route_editor
8180 .as_ref()
8181 .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
8182 }
8183
8184 pub fn re_open_add_menu(&mut self) {
8185 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8186 ed.open_add_menu();
8187 }
8188 }
8189
8190 pub fn re_open_bed_picker(&mut self) {
8191 let beds = self.re_bed_candidates();
8192 if beds.is_empty() {
8193 self.state
8194 .push_log("Route: place a camp bed first".to_string());
8195 return;
8196 }
8197 let current = self
8198 .state
8199 .worker_route_editor
8200 .as_ref()
8201 .and_then(|ed| ed.lodging_container_id.clone());
8202 let index = current
8203 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
8204 .unwrap_or(0);
8205 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
8206 }
8207
8208 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
8209 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8210 ed.open_sheet(sheet);
8211 }
8212 }
8213
8214 fn re_confirm_stop(
8216 &mut self,
8217 stop: crate::worker_route_editor::WorkerRouteStop,
8218 what: String,
8219 ) {
8220 let appended = self
8221 .state
8222 .worker_route_editor
8223 .as_mut()
8224 .is_some_and(|ed| ed.confirm_stop(stop));
8225 if appended {
8226 self.state.push_log(format!("Route: + {what}"));
8227 } else {
8228 self.state
8229 .push_log(format!("Route: {what} already in route — selected it"));
8230 }
8231 }
8232
8233 fn re_open_withdraw_items(&mut self, container_id: String) {
8234 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
8235 let contents = self.re_container_contents(&container_id);
8236 let existing = self
8240 .state
8241 .worker_route_editor
8242 .as_ref()
8243 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
8244 .and_then(|stop| match stop {
8245 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
8246 _ => None,
8247 })
8248 .unwrap_or_default();
8249 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
8250 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8253 let _ = ed.retarget_withdraw_container(container_id.clone());
8254 }
8255 self.re_open_sheet(S::WithdrawItems {
8256 container_id,
8257 lines,
8258 index: 0,
8259 });
8260 }
8261
8262 fn re_withdraw_items_activate(&mut self, index: usize) {
8263 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
8264 enum Outcome {
8265 Cycled,
8266 Confirmed(String),
8267 Empty,
8268 }
8269 let outcome = {
8270 let Some(ed) = self.state.worker_route_editor.as_mut() else {
8271 return;
8272 };
8273 let S::WithdrawItems {
8274 container_id,
8275 lines,
8276 index: sheet_index,
8277 } = &mut ed.sheet
8278 else {
8279 return;
8280 };
8281 *sheet_index = index;
8282 if index < lines.len() {
8283 lines[index].cycle();
8284 Outcome::Cycled
8285 } else {
8286 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
8287 if items.is_empty() {
8288 Outcome::Empty
8289 } else {
8290 let stop = WorkerRouteStop::WithdrawFrom {
8291 container_id: container_id.clone(),
8292 items,
8293 };
8294 let summary = stop.summary();
8295 ed.confirm_stop(stop);
8296 Outcome::Confirmed(summary)
8297 }
8298 }
8299 };
8300 match outcome {
8301 Outcome::Cycled => {}
8302 Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
8303 Outcome::Empty => self
8304 .state
8305 .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
8306 }
8307 }
8308
8309 fn re_open_deposit_filter(&mut self, container_id: String) {
8310 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8311 let existing_filter = self
8313 .state
8314 .worker_route_editor
8315 .as_ref()
8316 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
8317 .and_then(|stop| match stop {
8318 WorkerRouteStop::DepositAt { filter, .. } => {
8319 Some(filter.clone().unwrap_or_default())
8320 }
8321 _ => None,
8322 });
8323 let mut candidates = self.re_template_candidates();
8324 if let Some(ref chosen) = existing_filter {
8325 for t in chosen {
8326 if !candidates.iter().any(|c| c == t) {
8327 candidates.push(t.clone());
8328 }
8329 }
8330 candidates.sort();
8331 candidates.dedup();
8332 }
8333 let rows: Vec<(String, bool)> = match existing_filter {
8334 Some(chosen) => candidates
8335 .iter()
8336 .map(|t| (t.clone(), chosen.contains(t)))
8337 .collect(),
8338 None => candidates.into_iter().map(|t| (t, false)).collect(),
8339 };
8340 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8341 let _ = ed.retarget_deposit_container(container_id.clone());
8342 }
8343 self.re_open_sheet(S::DepositFilter {
8344 container_id,
8345 rows,
8346 index: 0,
8347 });
8348 }
8349
8350 fn re_deposit_filter_activate(&mut self, index: usize) {
8351 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8352 let mut confirmed: Option<String> = None;
8353 {
8354 let Some(ed) = self.state.worker_route_editor.as_mut() else {
8355 return;
8356 };
8357 let S::DepositFilter {
8358 container_id,
8359 rows,
8360 index: sheet_index,
8361 } = &mut ed.sheet
8362 else {
8363 return;
8364 };
8365 *sheet_index = index;
8366 if index < rows.len() {
8367 rows[index].1 = !rows[index].1;
8368 } else {
8369 let chosen: Vec<String> = rows
8371 .iter()
8372 .filter(|(_, on)| *on)
8373 .map(|(t, _)| t.clone())
8374 .collect();
8375 let filter = if chosen.is_empty() { None } else { Some(chosen) };
8376 let stop = WorkerRouteStop::DepositAt {
8377 container_id: container_id.clone(),
8378 filter,
8379 };
8380 confirmed = Some(stop.summary());
8381 ed.confirm_stop(stop);
8382 }
8383 }
8384 if let Some(what) = confirmed {
8385 self.state.push_log(format!("Route: + {what}"));
8386 }
8387 }
8388
8389 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
8390 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8391 let templates = self.re_template_candidates();
8392 if templates.is_empty() {
8393 self.state.push_log(
8394 "Route: no item templates available — learn a craft recipe or place a harvest node first"
8395 .to_string(),
8396 );
8397 return;
8398 }
8399 let (pre_npc, pre_template, pre_all) = self
8401 .state
8402 .worker_route_editor
8403 .as_ref()
8404 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
8405 .and_then(|stop| match stop {
8406 WorkerRouteStop::TradeWith {
8407 npc_id,
8408 template,
8409 sell_all,
8410 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
8411 _ => None,
8412 })
8413 .unwrap_or((None, None, true));
8414 let npc_id = npc_id.or(pre_npc);
8415 let index = pre_template
8416 .and_then(|t| templates.iter().position(|x| x == &t))
8417 .map(|i| i + 1) .unwrap_or(1);
8419 self.re_open_sheet(S::SellItem {
8420 npc_id,
8421 templates,
8422 index,
8423 sell_all: pre_all,
8424 });
8425 }
8426
8427 fn re_sell_item_activate(&mut self, index: usize) {
8428 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8429 let mut confirmed: Option<String> = None;
8430 {
8431 let Some(ed) = self.state.worker_route_editor.as_mut() else {
8432 return;
8433 };
8434 let S::SellItem {
8435 npc_id,
8436 templates,
8437 index: sheet_index,
8438 sell_all,
8439 } = &mut ed.sheet
8440 else {
8441 return;
8442 };
8443 *sheet_index = index;
8444 if index == 0 {
8445 *sell_all = !*sell_all;
8446 } else if let Some(template) = templates.get(index - 1).cloned() {
8447 let stop = WorkerRouteStop::TradeWith {
8448 npc_id: npc_id.clone(),
8449 template,
8450 sell_all: *sell_all,
8451 };
8452 confirmed = Some(stop.summary());
8453 ed.confirm_stop(stop);
8454 }
8455 }
8456 if let Some(what) = confirmed {
8457 self.state.push_log(format!("Route: + {what}"));
8458 }
8459 }
8460
8461 pub fn re_edit_selected_stop(&mut self) {
8463 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8464 let Some(stop) = self
8465 .state
8466 .worker_route_editor
8467 .as_ref()
8468 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
8469 else {
8470 self.state
8471 .push_log("Route: no stop selected — press a to add one".to_string());
8472 return;
8473 };
8474 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8475 ed.begin_edit_selected();
8476 }
8477 match stop {
8478 WorkerRouteStop::Waypoint { .. } => {
8479 self.re_open_sheet(S::WaypointMenu { index: 0 });
8480 }
8481 WorkerRouteStop::HarvestNode { node_id } => {
8482 let nodes = self.re_node_candidates();
8483 let index = nodes.iter().position(|n| n.id == node_id).unwrap_or(0);
8484 if nodes.is_empty() {
8485 self.re_cancel_edit();
8486 self.state
8487 .push_log("Route: no harvestable nodes visible to retarget".to_string());
8488 } else {
8489 self.re_open_sheet(S::HarvestPicker { index });
8490 }
8491 }
8492 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
8493 let containers = self.re_container_candidates();
8496 if containers.is_empty() {
8497 self.re_cancel_edit();
8498 self.state
8499 .push_log("Route: place a storage chest first".to_string());
8500 } else {
8501 let index = containers
8502 .iter()
8503 .position(|c| c.id == container_id)
8504 .unwrap_or(0);
8505 self.re_open_sheet(S::WithdrawContainers { index });
8506 }
8507 }
8508 WorkerRouteStop::DepositAt { container_id, .. } => {
8509 let containers = self.re_container_candidates();
8510 if containers.is_empty() {
8511 self.re_cancel_edit();
8512 self.state
8513 .push_log("Route: place a storage chest first".to_string());
8514 } else {
8515 let index = containers
8516 .iter()
8517 .position(|c| c.id == container_id)
8518 .unwrap_or(0);
8519 self.re_open_sheet(S::DepositContainers { index });
8520 }
8521 }
8522 WorkerRouteStop::TradeWith { npc_id, .. } => {
8523 let npcs = self.re_npc_candidates();
8524 let index = npc_id
8526 .as_ref()
8527 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
8528 .unwrap_or(0);
8529 self.re_open_sheet(S::SellNpcs { index });
8530 }
8531 WorkerRouteStop::CraftAt { blueprint, .. } => {
8532 let bps = self.re_blueprint_ids();
8533 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
8534 if bps.is_empty() {
8535 self.re_cancel_edit();
8536 self.state
8537 .push_log("Route: no known blueprints to retarget".to_string());
8538 } else {
8539 self.re_open_sheet(S::CraftBlueprint { index });
8540 }
8541 }
8542 WorkerRouteStop::RestIfNeeded => {
8543 self.re_cancel_edit();
8544 self.state
8545 .push_log("Route: rest has no settings (change the bed with l)".to_string());
8546 }
8547 WorkerRouteStop::Wait { wait_ticks } => {
8548 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
8549 }
8550 }
8551 }
8552
8553 fn re_cancel_edit(&mut self) {
8554 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8555 ed.editing_index = None;
8556 }
8557 }
8558
8559 pub fn worker_route_editor_ui_click(
8562 &mut self,
8563 click: crate::worker_route_editor::RouteEditorClick,
8564 ) {
8565 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
8566 match click {
8567 RouteEditorClick::SelectStop(i) => {
8568 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8569 ed.sheet = S::Stops;
8570 ed.select_stop(i);
8571 }
8572 }
8573 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
8574 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
8575 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
8576 }
8577 }
8578
8579 pub fn re_sheet_row_activate(&mut self, row: usize) {
8581 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8582 let Some(sheet) = self
8583 .state
8584 .worker_route_editor
8585 .as_ref()
8586 .map(|ed| ed.sheet.clone())
8587 else {
8588 return;
8589 };
8590 match sheet {
8591 S::Stops => {
8592 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8593 ed.select_stop(row);
8594 }
8595 }
8596 S::AddMenu { .. } => match row {
8597 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
8598 1 => {
8599 if self.re_node_candidates().is_empty() {
8600 self.state
8601 .push_log("Route: no harvestable nodes visible in this region".to_string());
8602 } else {
8603 self.re_open_sheet(S::HarvestPicker { index: 0 });
8604 }
8605 }
8606 2 | 3 => {
8607 if self.re_container_candidates().is_empty() {
8608 self.state
8609 .push_log("Route: place a storage chest first".to_string());
8610 } else if row == 2 {
8611 self.re_open_sheet(S::WithdrawContainers { index: 0 });
8612 } else {
8613 self.re_open_sheet(S::DepositContainers { index: 0 });
8614 }
8615 }
8616 4 => {
8617 if self.re_template_candidates().is_empty() {
8618 self.state.push_log(
8619 "Route: no item templates available — learn a craft recipe or place a harvest node first"
8620 .to_string(),
8621 );
8622 } else {
8623 self.re_open_sheet(S::SellNpcs { index: 0 });
8624 }
8625 }
8626 5 => {
8627 if self.re_blueprint_ids().is_empty() {
8628 self.state.push_log(
8629 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
8630 .to_string(),
8631 );
8632 } else {
8633 self.re_open_sheet(S::CraftBlueprint { index: 0 });
8634 }
8635 }
8636 6 => self.re_confirm_stop(
8637 WorkerRouteStop::RestIfNeeded,
8638 "rest if needed".into(),
8639 ),
8640 7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
8641 _ => {}
8642 },
8643 S::WaypointMenu { .. } => match row {
8644 0 => {
8645 let (x, y, z) = self.state.player_position_with_z();
8646 let stop = WorkerRouteStop::Waypoint { x, y, z };
8647 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
8648 }
8649 1 => {
8650 self.re_open_sheet(S::WaypointMapPick);
8651 self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
8652 }
8653 _ => {}
8654 },
8655 S::HarvestPicker { .. } => {
8656 let nodes = self.re_node_candidates();
8657 if let Some(n) = nodes.get(row) {
8658 let stop = WorkerRouteStop::HarvestNode {
8659 node_id: n.id.clone(),
8660 };
8661 let label = n.label.clone();
8662 self.re_confirm_stop(stop, format!("harvest {label}"));
8663 }
8664 }
8665 S::WithdrawContainers { .. } => {
8666 let containers = self.re_container_candidates();
8667 if let Some(c) = containers.get(row) {
8668 let id = c.id.clone();
8669 self.re_open_withdraw_items(id);
8670 }
8671 }
8672 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
8673 S::DepositContainers { .. } => {
8674 let containers = self.re_container_candidates();
8675 if let Some(c) = containers.get(row) {
8676 let id = c.id.clone();
8677 self.re_open_deposit_filter(id);
8678 }
8679 }
8680 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
8681 S::SellNpcs { .. } => {
8682 let npcs = self.re_npc_candidates();
8683 let npc_id = if row == 0 {
8684 None
8685 } else {
8686 npcs.get(row - 1).map(|n| n.id.clone())
8687 };
8688 if row == 0 || npc_id.is_some() {
8689 self.re_open_sell_item(npc_id);
8690 }
8691 }
8692 S::SellItem { .. } => self.re_sell_item_activate(row),
8693 S::CraftBlueprint { .. } => {
8694 let bps = self.re_blueprint_ids();
8695 if let Some(bp) = bps.get(row) {
8696 let stop = WorkerRouteStop::CraftAt {
8697 device: "hand".into(),
8698 blueprint: bp.clone(),
8699 qty: None,
8700 };
8701 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
8702 }
8703 }
8704 S::WaitEntry { ticks } => {
8705 let stop = WorkerRouteStop::Wait {
8706 wait_ticks: ticks,
8707 };
8708 self.re_confirm_stop(stop, format!("wait {ticks}t"));
8709 }
8710 S::BedPicker { .. } => {
8711 let beds = self.re_bed_candidates();
8712 if let Some((id, name)) = beds.get(row) {
8713 let (id, name) = (id.clone(), name.clone());
8714 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8715 ed.lodging_container_id = Some(id.clone());
8716 ed.sheet = S::Stops;
8717 }
8718 self.state
8719 .push_log(format!("Route: rest bed set to {name}"));
8720 }
8721 }
8722 S::WaypointMapPick => {}
8723 }
8724 }
8725
8726 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
8733 use crate::worker_route_editor as wre;
8734 use wre::RouteEditorSheet as S;
8735 if self.state.worker_route_editor.is_none() {
8736 return;
8737 }
8738 let sheet = self
8739 .state
8740 .worker_route_editor
8741 .as_ref()
8742 .map(|ed| ed.sheet.clone())
8743 .unwrap_or(S::Stops);
8744 match sheet {
8745 S::WaypointMapPick => {
8746 let (_, _, z) = self.state.player_position_with_z();
8747 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
8748 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
8749 let editing = self
8751 .state
8752 .worker_route_editor
8753 .as_ref()
8754 .is_some_and(|ed| ed.editing_index.is_some());
8755 if !editing {
8756 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8757 ed.sheet = S::WaypointMapPick;
8758 }
8759 }
8760 }
8761 S::HarvestPicker { .. } => {
8762 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
8763 let stop = wre::WorkerRouteStop::HarvestNode {
8764 node_id: node.id.clone(),
8765 };
8766 let label = node.label.clone();
8767 self.re_confirm_stop(stop, format!("harvest {label}"));
8768 }
8769 }
8770 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
8771 if let Some(cid) = wre::pick_storage_container_at(
8773 &self.state.placed_containers,
8774 self.state.character_id,
8775 x,
8776 y,
8777 ) {
8778 self.re_open_withdraw_items(cid);
8779 }
8780 }
8781 S::DepositContainers { .. } | S::DepositFilter { .. } => {
8782 if let Some(cid) = wre::pick_storage_container_at(
8783 &self.state.placed_containers,
8784 self.state.character_id,
8785 x,
8786 y,
8787 ) {
8788 self.re_open_deposit_filter(cid);
8789 }
8790 }
8791 S::SellNpcs { .. } => {
8792 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8793 self.re_open_sell_item(Some(npc_id));
8794 }
8795 }
8796 S::SellItem { .. } => {
8797 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8798 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8799 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
8800 *slot = Some(npc_id.clone());
8801 }
8802 }
8803 self.state
8804 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
8805 }
8806 }
8807 _ => self.worker_route_editor_quick_add_click(x, y),
8809 }
8810 }
8811
8812 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
8816 use crate::worker_route_editor as wre;
8817 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
8818 let dx = ax - bx;
8819 let dy = ay - by;
8820 (dx * dx + dy * dy).sqrt()
8821 };
8822
8823 let selected_stop_kind = self
8826 .state
8827 .worker_route_editor
8828 .as_ref()
8829 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
8830 .map(|s| match s {
8831 wre::WorkerRouteStop::TradeWith { .. } => 1,
8832 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
8833 _ => 0,
8834 })
8835 .unwrap_or(0);
8836 if selected_stop_kind == 1 {
8837 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8838 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8839 ed.set_selected_trade_npc(npc_id.clone());
8840 }
8841 self.state
8842 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
8843 return;
8844 }
8845 }
8846 if selected_stop_kind == 2 {
8847 if let Some(cid) = wre::pick_storage_container_at(
8848 &self.state.placed_containers,
8849 self.state.character_id,
8850 x,
8851 y,
8852 ) {
8853 let name = self
8854 .state
8855 .placed_containers
8856 .iter()
8857 .find(|c| c.id == cid)
8858 .map(|c| c.display_name.clone())
8859 .unwrap_or_else(|| "container".into());
8860 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8861 ed.set_selected_withdraw_container(cid.clone());
8862 }
8863 self.state
8864 .push_log(format!("Route: withdraw source → {name}"));
8865 return;
8866 }
8867 }
8868
8869 enum Target {
8872 Bed(String),
8873 Container(String),
8874 Npc(String, String),
8875 Node(String, String),
8876 }
8877 let mut best: Option<(f32, u8, Target)> = None;
8878 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
8879 let better = match best {
8880 None => true,
8881 Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
8882 };
8883 if better {
8884 *best = Some((d, rank, t));
8885 }
8886 };
8887 if let Some(bed_id) = wre::pick_lodging_container_at(
8888 &self.state.placed_containers,
8889 self.state.character_id,
8890 x,
8891 y,
8892 ) {
8893 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
8894 let already_bed = self
8897 .state
8898 .worker_route_editor
8899 .as_ref()
8900 .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
8901 if already_bed {
8902 consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
8903 } else {
8904 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
8905 }
8906 }
8907 }
8908 if let Some(cid) = wre::pick_storage_container_at(
8909 &self.state.placed_containers,
8910 self.state.character_id,
8911 x,
8912 y,
8913 ) {
8914 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
8915 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
8916 }
8917 }
8918 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8919 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
8920 consider(
8921 dist(x, y, n.x, n.y),
8922 2,
8923 Target::Npc(npc_id, label),
8924 &mut best,
8925 );
8926 }
8927 }
8928 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
8929 let d = dist(x, y, node.x, node.y);
8930 consider(
8931 d,
8932 3,
8933 Target::Node(node.id.clone(), node.label.clone()),
8934 &mut best,
8935 );
8936 }
8937
8938 match best.map(|(_, _, t)| t) {
8939 Some(Target::Bed(bed_id)) => {
8940 let name = self
8941 .state
8942 .placed_containers
8943 .iter()
8944 .find(|c| c.id == bed_id)
8945 .map(|c| c.display_name.clone())
8946 .unwrap_or_else(|| "camp bed".into());
8947 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8948 ed.lodging_container_id = Some(bed_id.clone());
8949 }
8950 self.state
8951 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
8952 }
8953 Some(Target::Container(cid)) => {
8954 let name = self
8955 .state
8956 .placed_containers
8957 .iter()
8958 .find(|c| c.id == cid)
8959 .map(|c| c.display_name.clone())
8960 .unwrap_or_else(|| "container".into());
8961 let added = self
8962 .state
8963 .worker_route_editor
8964 .as_mut()
8965 .is_some_and(|ed| ed.append_deposit_at(&cid));
8966 if added {
8967 self.state
8968 .push_log(format!("Route: + deposit at {name} ({cid})"));
8969 } else {
8970 self.state.push_log(format!(
8971 "Route: {name} already in route — selected it (d to remove)"
8972 ));
8973 }
8974 }
8975 Some(Target::Npc(npc_id, label)) => {
8976 let template = self.re_template_candidates().into_iter().next();
8979 let Some(template) = template else {
8980 self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
8981 return;
8982 };
8983 let added = self
8984 .state
8985 .worker_route_editor
8986 .as_mut()
8987 .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
8988 if added {
8989 self.state
8990 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
8991 } else {
8992 self.state.push_log(format!(
8993 "Route: {label} already sells {template} — selected it (d to remove)"
8994 ));
8995 }
8996 }
8997 Some(Target::Node(id, label)) => {
8998 let added = self
8999 .state
9000 .worker_route_editor
9001 .as_mut()
9002 .is_some_and(|ed| ed.append_harvest_node(&id));
9003 if added {
9004 self.state
9005 .push_log(format!("Route: + harvest node {label} ({id})"));
9006 } else {
9007 self.state.push_log(format!(
9008 "Route: {label} already in route — selected it (d to remove)"
9009 ));
9010 }
9011 }
9012 None => {}
9013 }
9014 }
9015
9016 pub fn worker_route_editor_select(&mut self, delta: i32) {
9017 let Some(ed) = self.state.worker_route_editor.as_mut() else {
9018 return;
9019 };
9020 if ed.stops.is_empty() {
9021 return;
9022 }
9023 let n = ed.stops.len() as i32;
9024 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
9025 ed.selected_stop_index = next;
9026 }
9027
9028 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
9029 let Some(ed) = self.state.worker_route_editor.as_mut() else {
9030 return;
9031 };
9032 if delta < 0 {
9033 ed.move_selected_up();
9034 } else if delta > 0 {
9035 ed.move_selected_down();
9036 }
9037 }
9038
9039 pub fn worker_route_editor_delete_selected(&mut self) {
9040 let removed = self
9041 .state
9042 .worker_route_editor
9043 .as_mut()
9044 .is_some_and(|ed| {
9045 let before = ed.stop_count();
9046 ed.remove_selected_stop();
9047 ed.stop_count() < before
9048 });
9049 if removed {
9050 self.state.push_log("Route: removed selected stop");
9051 }
9052 }
9053
9054 pub fn worker_route_editor_clear_stops(&mut self) {
9057 let Some(ed) = self.state.worker_route_editor.as_mut() else {
9058 return;
9059 };
9060 if ed.stops.is_empty() {
9061 self.state.push_log("Route: already empty — s saves an idle worker".to_string());
9062 return;
9063 }
9064 ed.stops.clear();
9065 ed.selected_stop_index = 0;
9066 self.state
9067 .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
9068 }
9069
9070 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
9071 if self.state.pending_worker_job_ack.is_some() {
9072 anyhow::bail!("route save still pending — wait for server ack");
9073 }
9074 let Some(ed) = self.state.worker_route_editor.clone() else {
9075 anyhow::bail!("route editor not open");
9076 };
9077 let (job_yaml, idle) = if ed.stops.is_empty() {
9080 (ed.build_idle_job_yaml(), true)
9081 } else {
9082 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
9083 };
9084 let worker_id = ed.worker_instance_id.clone();
9085 let route_view = if idle {
9086 None
9087 } else {
9088 Some(ed.to_route_view())
9089 };
9090 let mode = if idle {
9091 flatland_protocol::WorkerModeView::Idle
9092 } else {
9093 flatland_protocol::WorkerModeView::JobLoop
9094 };
9095 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
9096 .state
9097 .hired_workers
9098 .iter()
9099 .find(|w| w.instance_id == worker_id)
9100 .map(|w| {
9101 (
9102 w.route.clone(),
9103 w.mode,
9104 w.step_label.clone(),
9105 w.last_error.clone(),
9106 )
9107 })
9108 .unwrap_or((
9109 None,
9110 flatland_protocol::WorkerModeView::Idle,
9111 String::new(),
9112 None,
9113 ));
9114 self.seq += 1;
9115 let seq = self.seq;
9116 self.session
9117 .submit_intent(Intent::SetWorkerJob {
9118 entity_id: self.state.entity_id,
9119 worker_instance_id: worker_id.clone(),
9120 job_yaml,
9121 seq,
9122 })
9123 .await?;
9124 self.state.intents_sent += 1;
9125 if let Some(w) = self
9126 .state
9127 .hired_workers
9128 .iter_mut()
9129 .find(|w| w.instance_id == worker_id)
9130 {
9131 w.route = route_view;
9132 w.mode = mode;
9133 w.last_error = None;
9134 if idle {
9135 w.step_label.clear();
9136 }
9137 }
9138 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
9139 seq,
9140 worker_instance_id: worker_id,
9141 worker_label: ed.worker_label.clone(),
9142 idle,
9143 stop_count: ed.stops.len(),
9144 prev_route,
9145 prev_mode,
9146 prev_step_label,
9147 prev_last_error,
9148 });
9149 self.state.push_log(format!(
9150 "Route: saving for {}… (waiting for server)",
9151 ed.worker_label
9152 ));
9153 Ok(())
9155 }
9156 pub fn quest_menu_move(&mut self, delta: i32) {
9157 let n = self.state.active_quest_entries().len();
9158 if n == 0 {
9159 return;
9160 }
9161 let idx = self.state.quest_menu_index as i32;
9162 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9163 }
9164
9165 pub fn quest_menu_page(&mut self, pages: i32) {
9166 let n = self.state.active_quest_entries().len();
9167 self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
9168 }
9169
9170 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
9171 let Some(offer) = self.state.pending_quest_offer.clone() else {
9172 anyhow::bail!("no quest offer");
9173 };
9174 self.seq += 1;
9175 let seq = self.seq;
9176 self.session
9177 .submit_intent(Intent::AcceptQuest {
9178 entity_id: self.state.entity_id,
9179 quest_id: offer.quest_id,
9180 seq,
9181 })
9182 .await?;
9183 self.state.intents_sent += 1;
9184 Ok(())
9185 }
9186
9187 pub fn quest_offer_decline(&mut self) {
9188 self.state.show_quest_offer = false;
9189 self.state.pending_quest_offer = None;
9190 if !self.state.show_npc_chat
9191 && !self.state.show_shop_menu
9192 && self.state.npc_verb_target.is_some()
9193 {
9194 self.state.show_npc_verb_menu = true;
9195 }
9196 }
9197
9198 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
9199 if !self.state.show_quest_menu {
9200 return Ok(());
9201 }
9202 let active: Vec<_> = self
9203 .state
9204 .active_quest_entries()
9205 .into_iter()
9206 .cloned()
9207 .collect();
9208 let Some(entry) = active.get(self.state.quest_menu_index) else {
9209 return Ok(());
9210 };
9211 if self.state.quest_withdraw_confirm {
9212 if !entry.can_withdraw {
9213 anyhow::bail!("quest cannot be withdrawn");
9214 }
9215 self.seq += 1;
9216 let seq = self.seq;
9217 self.session
9218 .submit_intent(Intent::WithdrawQuest {
9219 entity_id: self.state.entity_id,
9220 quest_id: entry.quest_id.clone(),
9221 seq,
9222 })
9223 .await?;
9224 self.state.intents_sent += 1;
9225 self.state.quest_withdraw_confirm = false;
9226 return Ok(());
9227 }
9228 self.seq += 1;
9229 let seq = self.seq;
9230 self.session
9231 .submit_intent(Intent::TrackQuest {
9232 entity_id: self.state.entity_id,
9233 quest_id: entry.quest_id.clone(),
9234 seq,
9235 })
9236 .await?;
9237 self.state.intents_sent += 1;
9238 Ok(())
9239 }
9240
9241 pub fn quest_request_withdraw(&mut self) {
9242 if self.state.show_quest_menu {
9243 self.state.quest_withdraw_confirm = true;
9244 }
9245 }
9246
9247 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
9248 if !self.state.is_alive() {
9249 anyhow::bail!("you are dead");
9250 }
9251 let Some(catalog) = self.state.shop_catalog.clone() else {
9252 anyhow::bail!("no shop open");
9253 };
9254 self.seq += 1;
9255 let seq = self.seq;
9256 match self.state.shop_tab {
9257 ShopTab::Buy => {
9258 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
9259 anyhow::bail!("nothing selected");
9260 };
9261 if offer.already_owned {
9262 anyhow::bail!("already owned");
9263 }
9264 self.session
9265 .submit_intent(Intent::ShopBuy {
9266 entity_id: self.state.entity_id,
9267 npc_id: catalog.npc_id.clone(),
9268 offer_id: offer.offer_id.clone(),
9269 quantity: self.state.shop_quantity,
9270 seq,
9271 })
9272 .await?;
9273 }
9274 ShopTab::Sell => {
9275 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
9276 anyhow::bail!("nothing to sell");
9277 };
9278 if line.quantity == 0 {
9279 anyhow::bail!("you have no {}", line.label);
9280 }
9281 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
9282 self.session
9283 .submit_intent(Intent::ShopSell {
9284 entity_id: self.state.entity_id,
9285 npc_id: catalog.npc_id.clone(),
9286 template_id: line.template_id.clone(),
9287 quantity,
9288 seq,
9289 })
9290 .await?;
9291 }
9292 }
9293 self.state.intents_sent += 1;
9294 Ok(())
9295 }
9296
9297 pub fn craft_menu_move(&mut self, delta: i32) {
9298 let n = self.state.blueprints.len();
9299 if n == 0 {
9300 return;
9301 }
9302 let idx = self.state.craft_menu_index as i32;
9303 let next = (idx + delta).rem_euclid(n as i32);
9304 self.state.craft_menu_index = next as usize;
9305 self.state.clamp_craft_batch_quantity();
9306 }
9307
9308 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
9309 self.state.craft_batch_adjust_quantity(delta);
9310 }
9311
9312 pub fn craft_batch_set_max(&mut self) {
9313 self.state.craft_batch_set_max();
9314 }
9315
9316 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
9317 let Some(blueprint) = self
9318 .state
9319 .blueprints
9320 .get(self.state.craft_menu_index)
9321 .cloned()
9322 else {
9323 anyhow::bail!("no blueprints known");
9324 };
9325 if !self.state.can_craft_blueprint(&blueprint) {
9326 let hint = self
9327 .state
9328 .craft_missing_hint(&blueprint)
9329 .unwrap_or_else(|| "missing materials".into());
9330 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
9331 }
9332 let count = self.state.craft_batch_quantity;
9333 let max = self.state.max_craft_batches(&blueprint);
9334 if max == 0 {
9335 anyhow::bail!("cannot craft {}", blueprint.label);
9336 }
9337 let batches = count.min(max);
9338 self.craft(&blueprint.id, Some(batches)).await?;
9339 self.state.show_craft_menu = false;
9340 Ok(())
9341 }
9342
9343 pub async fn move_by(
9344 &mut self,
9345 forward: f32,
9346 strafe: f32,
9347 vertical: f32,
9348 sprint: bool,
9349 ) -> anyhow::Result<()> {
9350 if !self.state.is_alive() {
9351 anyhow::bail!("you are dead");
9352 }
9353 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
9354 self.last_move_forward = forward;
9355 self.last_move_strafe = strafe;
9356 }
9357 self.seq += 1;
9358 self.session
9359 .submit_intent(Intent::Move {
9360 entity_id: self.state.entity_id,
9361 forward,
9362 strafe,
9363 vertical,
9364 sprint,
9365 seq: self.seq,
9366 })
9367 .await?;
9368 self.state.intents_sent += 1;
9369 Ok(())
9370 }
9371
9372 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
9373 if !self.state.connected {
9374 crate::harvest_trace!("harvest_nearest rejected: not connected");
9375 anyhow::bail!("not connected");
9376 }
9377 if !self.state.is_alive() {
9378 crate::harvest_trace!("harvest_nearest rejected: player dead");
9379 anyhow::bail!("you are dead");
9380 }
9381 if self.state.harvest_in_progress {
9382 if self.state.harvest_state_stale() {
9383 self.state.clear_harvest_state();
9384 } else {
9385 anyhow::bail!("already harvesting");
9386 }
9387 }
9388 let (px, py) = self
9389 .state
9390 .player
9391 .as_ref()
9392 .map(|p| (p.transform.position.x, p.transform.position.y))
9393 .unwrap_or((0.0, 0.0));
9394
9395 let available = self
9396 .state
9397 .resource_nodes
9398 .iter()
9399 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
9400 .count();
9401 let node_id = self
9402 .state
9403 .resource_nodes
9404 .iter()
9405 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
9406 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
9407 .min_by(|a, b| {
9408 let da = distance(px, py, a.x, a.y);
9409 let db = distance(px, py, b.x, b.y);
9410 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
9411 })
9412 .map(|n| n.id.clone());
9413
9414 let Some(node_id) = node_id else {
9415 let has_loot = self
9416 .state
9417 .ground_drops
9418 .iter()
9419 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
9420 if has_loot {
9421 return self.pickup_nearest().await;
9422 }
9423 anyhow::bail!(
9424 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
9425 );
9426 };
9427
9428 self.seq += 1;
9429 let seq = self.seq;
9430 crate::harvest_trace!(
9431 entity_id = self.state.entity_id,
9432 node_id = %node_id,
9433 seq,
9434 px,
9435 py,
9436 available_nodes = available,
9437 "submitting harvest intent"
9438 );
9439 self.session
9440 .submit_intent(Intent::Harvest {
9441 entity_id: self.state.entity_id,
9442 node_id,
9443 seq,
9444 })
9445 .await?;
9446 self.state.intents_sent += 1;
9447 self.state.harvest_in_progress = true;
9448 self.state.harvest_started_at = Some(Instant::now());
9449 self.state.push_log("Harvesting…");
9450 crate::harvest_trace!(
9451 entity_id = self.state.entity_id,
9452 seq,
9453 "harvest intent queued to session"
9454 );
9455 Ok(())
9456 }
9457
9458 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
9459 if !self.state.is_alive() {
9460 anyhow::bail!("you are dead");
9461 }
9462 let blueprint_id = self
9463 .state
9464 .blueprints
9465 .iter()
9466 .find(|bp| self.state.can_craft_blueprint(bp))
9467 .map(|bp| bp.id.clone())
9468 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
9469 self.craft(&blueprint_id, None).await
9470 }
9471
9472 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
9473 if !self.state.is_alive() {
9474 anyhow::bail!("you are dead");
9475 }
9476 self.seq += 1;
9477 self.session
9478 .submit_intent(Intent::Craft {
9479 entity_id: self.state.entity_id,
9480 blueprint_id: blueprint_id.to_string(),
9481 count,
9482 seq: self.seq,
9483 })
9484 .await?;
9485 self.state.intents_sent += 1;
9486 let (label, batches) = self
9487 .state
9488 .blueprints
9489 .iter()
9490 .find(|b| b.id == blueprint_id)
9491 .map(|b| {
9492 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
9493 (b.label.as_str(), n)
9494 })
9495 .unwrap_or((blueprint_id, count.unwrap_or(1)));
9496 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
9497 Ok(())
9498 }
9499
9500 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
9501 if !self.state.is_alive() {
9502 anyhow::bail!("you are dead");
9503 }
9504 let target_id = match self.state.nearest_interact_target() {
9505 Some(id) => id,
9506 None => {
9507 anyhow::bail!("nothing to interact with nearby");
9508 }
9509 };
9510 if self.state.npcs.iter().any(|n| n.id == target_id) {
9511 self.state.show_npc_verb_menu = true;
9512 self.state.npc_verb_target = Some(target_id);
9513 self.state.npc_verb_index = 0;
9514 return Ok(());
9515 }
9516 if let Ok(peer_id) = target_id.parse::<EntityId>() {
9517 if let Some(entity) = self
9518 .state
9519 .entities
9520 .iter()
9521 .find(|e| e.id == peer_id && e.id != self.state.entity_id)
9522 {
9523 self.state
9524 .player_verbs
9525 .open_for(peer_id, &entity.label);
9526 return Ok(());
9527 }
9528 }
9529 self.seq += 1;
9530 self.session
9531 .submit_intent(Intent::Interact {
9532 entity_id: self.state.entity_id,
9533 target_id: target_id.clone(),
9534 seq: self.seq,
9535 })
9536 .await?;
9537 self.state.intents_sent += 1;
9538 Ok(())
9539 }
9540
9541 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
9543 if !self.state.is_alive() {
9544 anyhow::bail!("you are dead");
9545 }
9546 if self.state.nearest_interact_target().is_some() {
9547 return self.interact_nearest().await;
9548 }
9549 if let Some((label, dist)) = self.state.nearest_quest_board() {
9552 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
9553 anyhow::bail!(
9554 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
9555 );
9556 }
9557 }
9558 let (px, py) = self.state.player_position();
9559 let has_loot = self
9560 .state
9561 .ground_drops
9562 .iter()
9563 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
9564 if has_loot {
9565 return self.pickup_nearest().await;
9566 }
9567 if self
9568 .state
9569 .placed_containers
9570 .iter()
9571 .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
9572 {
9573 return self.pickup_nearest_container().await;
9574 }
9575 match self.harvest_nearest().await {
9576 Ok(()) => Ok(()),
9577 Err(err) => {
9578 let msg = err.to_string();
9579 if msg.contains("no harvestable")
9580 || msg.contains("press p")
9581 || msg.contains("press f")
9582 {
9583 anyhow::bail!(
9584 "nothing to use nearby — stand by an NPC/door, loot (*), chest, or resource"
9585 );
9586 }
9587 Err(err)
9588 }
9589 }
9590 }
9591
9592 pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
9595 if !self.state.is_alive() {
9596 anyhow::bail!("you are dead");
9597 }
9598 let binding = self
9599 .state
9600 .hotbar_ability(slot)
9601 .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
9602 .to_string();
9603 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
9604 let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
9605 if qty == 0 {
9606 anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
9607 }
9608 return self.use_item(template_id).await;
9609 }
9610 let ability_id = binding;
9611 let target = if ability_id == "heal_touch" {
9612 Some(
9613 self.state
9614 .target_for_slot(2)
9615 .unwrap_or(self.state.entity_id),
9616 )
9617 } else {
9618 self.state
9619 .target_for_slot(1)
9620 .or_else(|| self.state.target_for_slot(2))
9621 };
9622 let Some(target_id) = target else {
9623 anyhow::bail!("no target — Tab to select, then press the hotbar key");
9624 };
9625 self.cast_ability(&ability_id, Some(target_id)).await
9626 }
9627
9628 pub async fn set_hotbar_slot(
9631 &mut self,
9632 slot: u8,
9633 ability_id: Option<&str>,
9634 ) -> anyhow::Result<()> {
9635 if !self.state.is_alive() {
9636 anyhow::bail!("you are dead");
9637 }
9638 if !(1..=9).contains(&slot) {
9639 anyhow::bail!("hotbar slot must be 1–9");
9640 }
9641 let ability_id = ability_id
9642 .map(str::trim)
9643 .filter(|id| !id.is_empty())
9644 .map(str::to_string);
9645 self.seq += 1;
9646 self.session
9647 .submit_intent(Intent::SetHotbarSlot {
9648 entity_id: self.state.entity_id,
9649 slot,
9650 ability_id: ability_id.clone(),
9651 seq: self.seq,
9652 })
9653 .await?;
9654 self.state.intents_sent += 1;
9655 let idx = (slot - 1) as usize;
9656 if self.state.hotbar.len() < 9 {
9657 self.state.hotbar.resize(9, None);
9658 }
9659 if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
9660 *slot_mut = ability_id.clone();
9661 }
9662 match ability_id {
9663 Some(id) => {
9664 let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
9665 format!("use {tid}")
9666 } else {
9667 id
9668 };
9669 self.state.push_log(format!("Hotbar {slot} → {label}"))
9670 }
9671 None => self.state.push_log(format!("Hotbar {slot} cleared")),
9672 }
9673 Ok(())
9674 }
9675
9676 pub fn npc_verb_options(&self) -> Vec<&'static str> {
9677 self.state.npc_verb_options()
9678 }
9679
9680 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
9681 let Some(npc_id) = self.state.npc_verb_target.clone() else {
9682 return Ok(());
9683 };
9684 let options = self.npc_verb_options();
9685 let choice = options
9686 .get(self.state.npc_verb_index)
9687 .copied()
9688 .unwrap_or("Talk");
9689 self.seq += 1;
9690 match choice {
9691 "Trade" | "Bank" | "Storage" => {
9692 self.session
9693 .submit_intent(Intent::Interact {
9694 entity_id: self.state.entity_id,
9695 target_id: npc_id,
9696 seq: self.seq,
9697 })
9698 .await?;
9699 }
9700 _ => {
9701 self.session
9702 .submit_intent(Intent::NpcTalkOpen {
9703 entity_id: self.state.entity_id,
9704 npc_id,
9705 seq: self.seq,
9706 })
9707 .await?;
9708 }
9709 }
9710 self.state.intents_sent += 1;
9711 Ok(())
9712 }
9713
9714 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
9715 let Some(chat) = self.state.npc_chat.clone() else {
9716 return Ok(());
9717 };
9718 let message = chat.input.trim().to_string();
9719 if message.is_empty() || chat.pending {
9720 return Ok(());
9721 }
9722 if let Some(c) = self.state.npc_chat.as_mut() {
9723 c.lines.push(format!("You: {message}"));
9724 c.input.clear();
9725 c.pending = true;
9726 }
9727 self.seq += 1;
9728 self.session
9729 .submit_intent(Intent::NpcTalkSay {
9730 entity_id: self.state.entity_id,
9731 npc_id: chat.npc_id,
9732 message,
9733 seq: self.seq,
9734 })
9735 .await?;
9736 self.state.intents_sent += 1;
9737 Ok(())
9738 }
9739
9740 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
9741 let return_to_verbs = self.state.npc_verb_target.is_some();
9742 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
9743 self.state.show_npc_chat = false;
9744 if return_to_verbs {
9745 self.state.show_npc_verb_menu = true;
9746 }
9747 return Ok(());
9748 };
9749 self.seq += 1;
9750 self.session
9751 .submit_intent(Intent::NpcTalkClose {
9752 entity_id: self.state.entity_id,
9753 npc_id,
9754 seq: self.seq,
9755 })
9756 .await?;
9757 self.state.intents_sent += 1;
9758 self.state.show_npc_chat = false;
9759 self.state.npc_chat = None;
9760 if return_to_verbs {
9761 self.state.show_npc_verb_menu = true;
9762 }
9763 Ok(())
9764 }
9765
9766 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
9768 if self.state.show_quest_offer
9769 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
9770 {
9771 self.quest_offer_decline();
9772 return Ok(());
9773 }
9774 if self.state.show_npc_chat {
9775 return self.npc_talk_close().await;
9776 }
9777 if self.state.show_shop_menu {
9778 return self.back_from_shop_menu().await;
9779 }
9780 if self.state.bank_panel.is_some() {
9781 if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
9782 self.bank_transfer_back();
9783 return Ok(());
9784 }
9785 return self.close_bank_panel().await;
9786 }
9787 if self.state.storage_panel.is_some() {
9788 if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
9789 self.storage_ui_back();
9790 return Ok(());
9791 }
9792 return self.close_storage_panel().await;
9793 }
9794 if self.state.show_npc_verb_menu {
9795 self.state.show_npc_verb_menu = false;
9796 self.state.npc_verb_target = None;
9797 }
9798 Ok(())
9799 }
9800
9801 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
9802 self.seq += 1;
9803 self.session
9804 .submit_intent(Intent::TestDamage {
9805 entity_id: self.state.entity_id,
9806 amount,
9807 seq: self.seq,
9808 })
9809 .await?;
9810 self.state.intents_sent += 1;
9811 Ok(())
9812 }
9813
9814 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
9815 self.cycle_combat_target_slot(1, reverse).await
9816 }
9817
9818 pub async fn cycle_combat_target_slot(
9819 &mut self,
9820 slot_index: u8,
9821 reverse: bool,
9822 ) -> anyhow::Result<()> {
9823 if !self.state.is_alive() {
9824 anyhow::bail!("you are dead");
9825 }
9826 let candidates = self.state.candidates_for_slot(slot_index);
9827 if candidates.is_empty() {
9828 anyhow::bail!("no targets nearby");
9829 }
9830 let current = self.state.target_for_slot(slot_index);
9831 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
9832 let next_idx = match idx {
9833 None => 0,
9834 Some(i) if reverse => {
9835 if i == 0 {
9836 candidates.len() - 1
9837 } else {
9838 i - 1
9839 }
9840 }
9841 Some(i) => (i + 1) % candidates.len(),
9842 };
9843 if idx == Some(next_idx) && candidates.len() == 1 {
9844 self.clear_combat_target_slot(slot_index).await?;
9845 return Ok(());
9846 }
9847 let (target_id, label) = candidates[next_idx].clone();
9848 self.set_combat_target_slot(slot_index, target_id, &label)
9849 .await
9850 }
9851
9852 pub async fn set_combat_target_slot(
9853 &mut self,
9854 slot_index: u8,
9855 target_id: EntityId,
9856 label: &str,
9857 ) -> anyhow::Result<()> {
9858 if !self.state.is_alive() {
9859 anyhow::bail!("you are dead");
9860 }
9861 self.seq += 1;
9862 self.session
9863 .submit_intent(Intent::SetTargetSlot {
9864 entity_id: self.state.entity_id,
9865 slot_index,
9866 target_id,
9867 seq: self.seq,
9868 })
9869 .await?;
9870 self.state.intents_sent += 1;
9871 if slot_index == 1 {
9872 self.state.combat_target = Some(target_id);
9873 self.state.combat_target_label = Some(label.to_string());
9874 }
9875 self.state
9876 .push_log(format!("Slot {slot_index} target: {label}"));
9877 Ok(())
9878 }
9879
9880 pub async fn set_combat_target(
9881 &mut self,
9882 target_id: EntityId,
9883 label: &str,
9884 ) -> anyhow::Result<()> {
9885 self.set_combat_target_slot(1, target_id, label).await
9886 }
9887
9888 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
9889 if slot_index == 1 && self.state.combat_target.is_none() {
9890 return Ok(());
9891 }
9892 self.seq += 1;
9893 self.session
9894 .submit_intent(Intent::ClearTargetSlot {
9895 entity_id: self.state.entity_id,
9896 slot_index,
9897 seq: self.seq,
9898 })
9899 .await?;
9900 if slot_index == 1 {
9901 self.state.combat_target = None;
9902 self.state.combat_target_label = None;
9903 }
9904 self.state.intents_sent += 1;
9905 self.state
9906 .push_log(format!("Slot {slot_index} target cleared"));
9907 Ok(())
9908 }
9909
9910 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
9911 self.clear_combat_target_slot(1).await
9912 }
9913
9914 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
9915 if !self.state.is_alive() {
9916 anyhow::bail!("you are dead");
9917 }
9918 self.seq += 1;
9919 self.session
9920 .submit_intent(Intent::AdvanceRotation {
9921 entity_id: self.state.entity_id,
9922 slot_index,
9923 seq: self.seq,
9924 })
9925 .await?;
9926 self.state.intents_sent += 1;
9927 Ok(())
9928 }
9929
9930 pub async fn assign_slot_preset(
9931 &mut self,
9932 slot_index: u8,
9933 preset_id: &str,
9934 ) -> anyhow::Result<()> {
9935 if !self.state.is_alive() {
9936 anyhow::bail!("you are dead");
9937 }
9938 self.seq += 1;
9939 self.session
9940 .submit_intent(Intent::AssignSlotPreset {
9941 entity_id: self.state.entity_id,
9942 slot_index,
9943 preset_id: preset_id.to_string(),
9944 seq: self.seq,
9945 })
9946 .await?;
9947 self.state.intents_sent += 1;
9948 if let Some(slot) = self
9949 .state
9950 .combat_slots
9951 .iter_mut()
9952 .find(|s| s.slot_index == slot_index)
9953 {
9954 slot.preset_id = Some(preset_id.to_string());
9955 if let Some(preset) = self
9956 .state
9957 .rotation_presets
9958 .iter()
9959 .find(|p| p.id == preset_id)
9960 {
9961 slot.preset_label = Some(preset.label.clone());
9962 slot.rotation = preset.abilities.clone();
9963 slot.rotation_index = 0;
9964 }
9965 }
9966 self.state
9967 .push_log(format!("T{slot_index} loadout → {preset_id}"));
9968 Ok(())
9969 }
9970
9971 pub async fn cast_ability(
9972 &mut self,
9973 ability_id: &str,
9974 target_id: Option<EntityId>,
9975 ) -> anyhow::Result<()> {
9976 if !self.state.is_alive() {
9977 anyhow::bail!("you are dead");
9978 }
9979 let target_id = target_id
9980 .or_else(|| self.state.target_for_slot(2))
9981 .or_else(|| self.state.target_for_slot(1))
9982 .unwrap_or(self.state.entity_id);
9983 self.seq += 1;
9984 self.session
9985 .submit_intent(Intent::Cast {
9986 entity_id: self.state.entity_id,
9987 ability_id: ability_id.to_string(),
9988 target_id,
9989 seq: self.seq,
9990 })
9991 .await?;
9992 self.state.intents_sent += 1;
9993 self.state
9994 .push_log(format!("Cast {ability_id} → {target_id}"));
9995 Ok(())
9996 }
9997
9998 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
9999 self.seq += 1;
10000 self.session
10001 .submit_intent(Intent::UpsertRotationPreset {
10002 entity_id: self.state.entity_id,
10003 preset: preset.clone(),
10004 seq: self.seq,
10005 })
10006 .await?;
10007 self.state.intents_sent += 1;
10008 if let Some(existing) = self
10009 .state
10010 .rotation_presets
10011 .iter_mut()
10012 .find(|p| p.id == preset.id)
10013 {
10014 *existing = preset.clone();
10015 } else {
10016 self.state.rotation_presets.push(preset.clone());
10017 }
10018 for slot in &mut self.state.combat_slots {
10019 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
10020 slot.preset_label = Some(preset.label.clone());
10021 slot.rotation = preset.abilities.clone();
10022 }
10023 }
10024 self.state
10025 .push_log(format!("Saved rotation: {}", preset.label));
10026 Ok(())
10027 }
10028
10029 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
10030 self.seq += 1;
10031 self.session
10032 .submit_intent(Intent::DeleteRotationPreset {
10033 entity_id: self.state.entity_id,
10034 preset_id: preset_id.to_string(),
10035 seq: self.seq,
10036 })
10037 .await?;
10038 self.state.intents_sent += 1;
10039 self.state.rotation_presets.retain(|p| p.id != preset_id);
10040 for slot in &mut self.state.combat_slots {
10041 if slot.preset_id.as_deref() == Some(preset_id) {
10042 slot.preset_id = None;
10043 slot.preset_label = None;
10044 slot.rotation.clear();
10045 slot.rotation_index = 0;
10046 }
10047 }
10048 self.state
10049 .push_log(format!("Deleted rotation: {preset_id}"));
10050 Ok(())
10051 }
10052
10053 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
10054 if !self.state.is_alive() {
10055 anyhow::bail!("you are dead");
10056 }
10057 let enabled = !self
10058 .state
10059 .combat_slots
10060 .iter()
10061 .find(|s| s.slot_index == slot_index)
10062 .map(|s| s.auto_enabled)
10063 .unwrap_or(false);
10064 self.seq += 1;
10065 self.session
10066 .submit_intent(Intent::SetAutoAttack {
10067 entity_id: self.state.entity_id,
10068 slot_index,
10069 enabled,
10070 seq: self.seq,
10071 })
10072 .await?;
10073 if slot_index == 1 {
10074 self.state.auto_attack = enabled;
10075 }
10076 self.state.intents_sent += 1;
10077 self.state.push_log(format!(
10078 "T{slot_index} auto {}",
10079 if enabled { "ON" } else { "OFF" }
10080 ));
10081 Ok(())
10082 }
10083
10084 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
10085 if !self.state.connected {
10086 anyhow::bail!("not connected");
10087 }
10088 if !self.state.is_alive() {
10089 anyhow::bail!("you are dead");
10090 }
10091 let (px, py) = self.state.player_position();
10092 if self
10093 .state
10094 .ground_drops
10095 .iter()
10096 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
10097 {
10098 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
10099 }
10100 self.seq += 1;
10101 self.session
10102 .submit_intent(Intent::Pickup {
10103 entity_id: self.state.entity_id,
10104 drop_id: None,
10105 seq: self.seq,
10106 })
10107 .await?;
10108 self.state.intents_sent += 1;
10109 Ok(())
10110 }
10111
10112 pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
10113 self.toggle_auto_attack_slot(1).await
10114 }
10115
10116 pub async fn dodge(&mut self) -> anyhow::Result<()> {
10117 if !self.state.is_alive() {
10118 anyhow::bail!("you are dead");
10119 }
10120 self.seq += 1;
10121 self.session
10122 .submit_intent(Intent::Dodge {
10123 entity_id: self.state.entity_id,
10124 seq: self.seq,
10125 })
10126 .await?;
10127 self.state.intents_sent += 1;
10128 self.state.push_log("Dodge!");
10129 Ok(())
10130 }
10131
10132 pub async fn lunge(&mut self) -> anyhow::Result<()> {
10133 if !self.state.is_alive() {
10134 anyhow::bail!("you are dead");
10135 }
10136 let (forward, strafe) = self.last_move_axes();
10137 self.seq += 1;
10138 self.session
10139 .submit_intent(Intent::Lunge {
10140 entity_id: self.state.entity_id,
10141 forward,
10142 strafe,
10143 seq: self.seq,
10144 })
10145 .await?;
10146 self.state.intents_sent += 1;
10147 self.state.push_log("Lunge!");
10148 Ok(())
10149 }
10150
10151 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
10152 if !self.state.is_alive() {
10153 anyhow::bail!("you are dead");
10154 }
10155 self.seq += 1;
10156 self.session
10157 .submit_intent(Intent::DirectionalJump {
10158 entity_id: self.state.entity_id,
10159 forward,
10160 strafe,
10161 seq: self.seq,
10162 })
10163 .await?;
10164 self.state.intents_sent += 1;
10165 self.state.push_log("Jump!");
10166 Ok(())
10167 }
10168
10169 pub fn last_move_axes(&self) -> (f32, f32) {
10171 (self.last_move_forward, self.last_move_strafe)
10172 }
10173
10174 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
10175 if !self.state.is_alive() {
10176 anyhow::bail!("you are dead");
10177 }
10178 self.seq += 1;
10179 self.session
10180 .submit_intent(Intent::Block {
10181 entity_id: self.state.entity_id,
10182 enabled,
10183 seq: self.seq,
10184 })
10185 .await?;
10186 self.state.intents_sent += 1;
10187 if enabled {
10188 self.state.push_log("Blocking");
10189 }
10190 Ok(())
10191 }
10192
10193 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
10194 if !self.state.is_alive() {
10195 anyhow::bail!("you are dead");
10196 }
10197 self.seq += 1;
10198 self.session
10199 .submit_intent(Intent::EquipMainhand {
10200 entity_id: self.state.entity_id,
10201 template_id,
10202 instance_id: None,
10203 seq: self.seq,
10204 })
10205 .await?;
10206 self.state.intents_sent += 1;
10207 Ok(())
10208 }
10209
10210 pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
10212 let idx = self.state.equip_menu_index;
10213 let slots = equip_paperdoll_rows(&self.state);
10214 let Some(row) = slots.get(idx) else {
10215 return Ok(());
10216 };
10217 match row {
10218 EquipPaperdollRow::Body { slot, filled } => {
10219 if *filled {
10220 self.equip_worn(*slot, None).await
10221 } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
10222 self.equip_worn(*slot, Some(inst)).await
10223 } else {
10224 self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
10225 Ok(())
10226 }
10227 }
10228 EquipPaperdollRow::Mainhand { filled } => {
10229 if *filled {
10230 self.unequip_mainhand().await
10231 } else if let Some(tid) = first_inventory_weapon(&self.state) {
10232 self.equip_mainhand(Some(tid)).await
10233 } else {
10234 self.state.push_log("No weapon in inventory".to_string());
10235 Ok(())
10236 }
10237 }
10238 EquipPaperdollRow::Offhand { filled, locked } => {
10239 if *locked {
10240 self.state
10241 .push_log("Offhand locked — two-handed weapon equipped".to_string());
10242 Ok(())
10243 } else if *filled {
10244 self.unequip_offhand().await
10245 } else if let Some(tid) = first_inventory_offhand(&self.state) {
10246 self.equip_offhand(Some(tid)).await
10247 } else {
10248 self.state
10249 .push_log("No offhand item in inventory".to_string());
10250 Ok(())
10251 }
10252 }
10253 }
10254 }
10255
10256 pub async fn say(
10257 &mut self,
10258 channel: flatland_protocol::ChatChannel,
10259 text: &str,
10260 ) -> anyhow::Result<()> {
10261 self.say_to(channel, text, None).await
10262 }
10263
10264 pub async fn say_to(
10265 &mut self,
10266 channel: flatland_protocol::ChatChannel,
10267 text: &str,
10268 to_entity: Option<EntityId>,
10269 ) -> anyhow::Result<()> {
10270 self.seq += 1;
10271 self.session
10272 .submit_intent(Intent::Say {
10273 entity_id: self.state.entity_id,
10274 channel,
10275 text: text.to_string(),
10276 to_entity,
10277 seq: self.seq,
10278 })
10279 .await?;
10280 self.state.intents_sent += 1;
10281 Ok(())
10282 }
10283
10284 pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
10285 let Some(peer) = self.state.player_verbs.target_entity else {
10286 return Ok(());
10287 };
10288 let label = self.state.player_verbs.target_label.clone();
10289 let choice = crate::social::PlayerVerbState::options()
10290 .get(self.state.player_verbs.index)
10291 .copied()
10292 .unwrap_or("Whisper");
10293 self.state.player_verbs.close();
10294 match choice {
10295 "Trade" => {
10296 self.seq += 1;
10299 self.session
10300 .submit_intent(Intent::TradeRequest {
10301 entity_id: self.state.entity_id,
10302 peer_entity_id: peer,
10303 seq: self.seq,
10304 })
10305 .await?;
10306 self.state.intents_sent += 1;
10307 self.state
10308 .social_chat
10309 .push_system(format!("Trade request sent to {label} — waiting for accept"));
10310 }
10311 "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
10312 _ => self.state.social_chat.focus_nearby(),
10313 }
10314 Ok(())
10315 }
10316
10317 pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
10318 let Some(pending) = self.state.social_chat.pending_trade.take() else {
10319 return Ok(());
10320 };
10321 self.seq += 1;
10322 self.session
10323 .submit_intent(Intent::TradeRespond {
10324 entity_id: self.state.entity_id,
10325 peer_entity_id: pending.from_entity,
10326 accept,
10327 seq: self.seq,
10328 })
10329 .await?;
10330 self.state.intents_sent += 1;
10331 if accept {
10332 self.state
10333 .social_chat
10334 .push_system(format!("Accepted trade with {}", pending.from_name));
10335 } else {
10336 self.state
10337 .social_chat
10338 .push_system(format!("Declined trade with {}", pending.from_name));
10339 }
10340 Ok(())
10341 }
10342
10343 pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
10344 let text = self.state.social_chat.buffer.trim().to_string();
10345 if text.is_empty() {
10346 return Ok(());
10347 }
10348 let thread = self.state.social_chat.thread;
10349 let channel = thread.channel();
10350 let to = thread.to_entity();
10351 self.state.social_chat.buffer.clear();
10352 self.say_to(channel, &text, to).await
10353 }
10354
10355 pub async fn trade_present_selected(
10356 &mut self,
10357 item_instance_id: uuid::Uuid,
10358 ) -> anyhow::Result<()> {
10359 self.trade_present_quantity(item_instance_id, None).await
10360 }
10361
10362 pub async fn trade_present_quantity(
10363 &mut self,
10364 item_instance_id: uuid::Uuid,
10365 quantity: Option<u32>,
10366 ) -> anyhow::Result<()> {
10367 self.seq += 1;
10368 self.session
10369 .submit_intent(Intent::TradePresent {
10370 entity_id: self.state.entity_id,
10371 item_instance_id,
10372 quantity,
10373 seq: self.seq,
10374 })
10375 .await?;
10376 self.state.intents_sent += 1;
10377 self.state.trade_ui.qty_entry = None;
10378 self.state.trade_ui.picking_inventory = false;
10379 Ok(())
10380 }
10381
10382 pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
10384 if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
10385 let qty = self.state.trade_ui.present_quantity();
10386 return self
10387 .trade_present_quantity(entry.item_instance_id, qty)
10388 .await;
10389 }
10390 if !self.state.trade_ui.picking_inventory {
10391 return Ok(());
10392 }
10393 let Some(stack) = self
10394 .state
10395 .inventory_stacks
10396 .get(self.state.trade_ui.inventory_index)
10397 .cloned()
10398 else {
10399 return Ok(());
10400 };
10401 let Some(id) = stack.item_instance_id else {
10402 return Ok(());
10403 };
10404 let label = stack
10405 .display_name
10406 .clone()
10407 .unwrap_or_else(|| stack.template_id.clone());
10408 if stack.quantity <= 1 {
10409 self.trade_present_quantity(id, Some(1)).await
10410 } else {
10411 self.state
10412 .trade_ui
10413 .begin_qty_entry(id, label, stack.quantity);
10414 Ok(())
10415 }
10416 }
10417
10418 pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
10419 self.seq += 1;
10420 self.session
10421 .submit_intent(Intent::TradeSetReady {
10422 entity_id: self.state.entity_id,
10423 ready,
10424 seq: self.seq,
10425 })
10426 .await?;
10427 self.state.intents_sent += 1;
10428 Ok(())
10429 }
10430
10431 pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
10432 self.seq += 1;
10433 self.session
10434 .submit_intent(Intent::TradeCancel {
10435 entity_id: self.state.entity_id,
10436 seq: self.seq,
10437 })
10438 .await?;
10439 self.state.intents_sent += 1;
10440 self.state.trade_ui.close();
10441 Ok(())
10442 }
10443
10444 pub async fn destroy_whisper_stone(
10445 &mut self,
10446 item_instance_id: uuid::Uuid,
10447 ) -> anyhow::Result<()> {
10448 self.seq += 1;
10449 self.session
10450 .submit_intent(Intent::DestroyWhisperStone {
10451 entity_id: self.state.entity_id,
10452 item_instance_id,
10453 seq: self.seq,
10454 })
10455 .await?;
10456 self.state.intents_sent += 1;
10457 Ok(())
10458 }
10459
10460 pub async fn stop(&mut self) -> anyhow::Result<()> {
10461 self.seq += 1;
10462 self.session
10463 .submit_intent(Intent::Stop {
10464 entity_id: self.state.entity_id,
10465 seq: self.seq,
10466 })
10467 .await?;
10468 self.state.intents_sent += 1;
10469 Ok(())
10470 }
10471
10472 pub fn disconnect(&self) {
10473 self.session.disconnect();
10474 }
10475}
10476
10477fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
10478 let dx = ax - bx;
10479 let dy = ay - by;
10480 (dx * dx + dy * dy).sqrt()
10481}
10482
10483#[cfg(test)]
10484mod tests {
10485 use std::collections::BTreeMap;
10486
10487 use super::*;
10488 use flatland_protocol::{
10489 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
10490 };
10491
10492 fn sample_state() -> GameState {
10493 let mut state = GameState {
10494 session_id: 1,
10495 entity_id: 1,
10496 character_id: None,
10497 tick: 0,
10498 chunk_rev: 0,
10499 content_rev: 0,
10500 publish_rev: 0,
10501 entities: vec![EntityState {
10502 id: 1,
10503 label: "You".into(),
10504 transform: Transform {
10505 position: WorldCoord::surface(128.0, 128.0),
10506 yaw: 0.0,
10507 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
10508 },
10509 vitals: None,
10510 attributes: None,
10511 skills: None,
10512 inside_building: None,
10513 tile_id: None,
10514 paperdoll_ref: None,
10515 presentation_state: None,
10516 sprite_mode: None,
10517 progression_xp: None,
10518 }],
10519 player: None,
10520 resource_nodes: vec![ResourceNodeView {
10521 id: "oak-1".into(),
10522 label: "Oak".into(),
10523 x: 130.0,
10524 y: 128.0,
10525 z: 0.0,
10526 item_template: "oak_log".into(),
10527 state: ResourceNodeState::Available,
10528 blocking: true,
10529 blocking_radius_m: 0.8,
10530 tile_id: None,
10531 paperdoll_ref: None,
10532 yaw: 0.0,
10533 pitch: 0.0,
10534 roll: 0.0,
10535 draw_scale: 1.0,
10536 sprite_mode: None,
10537 presentation_state: None,
10538 }],
10539 ground_drops: vec![],
10540 placed_containers: vec![],
10541 buildings: vec![BuildingView {
10542 id: "broker-hut".into(),
10543 label: "Broker".into(),
10544 x: 148.0,
10545 y: 118.0,
10546 width_m: 8.0,
10547 depth_m: 6.0,
10548 interior_blueprint: Some("broker_hut".into()),
10549 tags: vec![],
10550 }],
10551 doors: vec![flatland_protocol::DoorView {
10552 id: "door-1".into(),
10553 building_id: "broker-hut".into(),
10554 x: 148.0,
10555 y: 118.0,
10556 open: false,
10557 portal: Some("front".into()),
10558 }],
10559 interior_map: None,
10560 npcs: vec![],
10561 blueprints: vec![],
10562 world_width_m: 256.0,
10563 world_height_m: 256.0,
10564 terrain_zones: Vec::new(),
10565 z_platforms: Vec::new(),
10566 z_transitions: Vec::new(),
10567 world_clock: flatland_protocol::WorldClock::default(),
10568 inventory: std::collections::HashMap::new(),
10569 inventory_hints: std::collections::HashMap::new(),
10570 logs: VecDeque::new(),
10571 intents_sent: 0,
10572 ticks_received: 0,
10573 connected: true,
10574 disconnect_reason: None,
10575 show_stats: false,
10576 hud_log_hidden: false,
10577 show_equip_menu: false,
10578 equip_menu_index: 0,
10579 show_craft_menu: false,
10580 craft_menu_index: 0,
10581 craft_batch_quantity: 1,
10582 show_shop_menu: false,
10583 shop_catalog: None,
10584 bank_panel: None,
10585 bank_menu_index: 0,
10586 bank_ui_mode: BankUiMode::Menu,
10587 storage_panel: None,
10588 storage_menu_index: 0,
10589 storage_ui_mode: StorageUiMode::Menu,
10590 shop_tab: ShopTab::default(),
10591 shop_menu_index: 0,
10592 shop_quantity: 1,
10593 shop_trade_log: VecDeque::new(),
10594 show_npc_verb_menu: false,
10595 npc_verb_target: None,
10596 npc_verb_index: 0,
10597 player_verbs: crate::social::PlayerVerbState::default(),
10598 social_chat: crate::social::SocialChatState::default(),
10599 trade_ui: crate::social::TradeUiState::default(),
10600 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
10601 show_npc_chat: false,
10602 npc_chat: None,
10603 show_inventory_menu: false,
10604 inventory_menu_index: 0,
10605 inventory_tab: InventoryTab::OnPerson,
10606 inventory_filter: String::new(),
10607 inventory_filter_focused: false,
10608 show_move_picker: false,
10609 show_rename_prompt: false,
10610 show_worker_rename: false,
10611 rename_buffer: String::new(),
10612 move_picker_index: 0,
10613 move_picker: None,
10614 show_grant_picker: false,
10615 grant_picker_index: 0,
10616 grant_picker: None,
10617 show_destroy_picker: false,
10618 destroy_confirm_pending: false,
10619 destroy_picker: None,
10620 combat_target: None,
10621 combat_target_label: None,
10622 combat_fx: Vec::new(),
10623 in_combat: false,
10624 auto_attack: true,
10625 combat_has_los: false,
10626 attack_cd_ticks: 0,
10627 gcd_ticks: 0,
10628 weapon_ability_id: "unarmed".into(),
10629 mainhand_template_id: None,
10630 mainhand_label: None,
10631 offhand_template_id: None,
10632 offhand_label: None,
10633 mainhand_hand_slots: 1,
10634 defense: None,
10635 worn: BTreeMap::new(),
10636 carry_mass: 0.0,
10637 carry_mass_max: 0.0,
10638 encumbrance: flatland_protocol::EncumbranceState::Light,
10639 inventory_stacks: Vec::new(),
10640 keychain_stacks: Vec::new(),
10641 whisper_pouch_stacks: Vec::new(),
10642 combat_target_detail: None,
10643 statuses: Vec::new(),
10644 cast_progress: None,
10645 ability_cooldowns: Vec::new(),
10646 blocking_active: false,
10647 max_target_slots: 1,
10648 combat_slots: Vec::new(),
10649 rotation_presets: Vec::new(),
10650 known_abilities: Vec::new(),
10651 hotbar: vec![None; 9],
10652 max_abilities_per_rotation: 0,
10653 show_loadout_menu: false,
10654 show_keychain_menu: false,
10655 keychain_menu_index: 0,
10656 show_rotation_editor: false,
10657 loadout_menu_index: 0,
10658 loadout_hotbar_slot: 1,
10659 loadout_ability_index: 0,
10660 loadout_focus_presets: false,
10661 rotation_editor: RotationEditorState::default(),
10662 harvest_in_progress: false,
10663 harvest_started_at: None,
10664 pending_craft_ack: None,
10665 pending_worker_job_ack: None,
10666 quest_log: Vec::new(),
10667 interactables: Vec::new(),
10668 ledger: None,
10669 career: None,
10670 character_sheet_tab: CharacterSheetTab::Character,
10671 ledger_period: LedgerPeriod::Day,
10672 show_quest_offer: false,
10673 pending_quest_offer: None,
10674 show_quest_menu: false,
10675 quest_menu_index: 0,
10676 quest_withdraw_confirm: false,
10677 hired_workers: Vec::new(),
10678 show_workers_menu: false,
10679 workers_menu_index: 0,
10680 workers_menu_compact: false,
10681 worker_step_display: BTreeMap::new(),
10682 worker_error_display: BTreeMap::new(),
10683 show_worker_give_picker: false,
10684 worker_give_picker_index: 0,
10685 worker_give_picker: None,
10686 show_worker_give_target_picker: false,
10687 worker_give_target_picker_index: 0,
10688 worker_give_target_picker: None,
10689 show_worker_take_picker: false,
10690 worker_take_picker_index: 0,
10691 worker_take_picker: None,
10692 show_worker_teach_picker: false,
10693 worker_teach_picker_index: 0,
10694 worker_teach_picker: None,
10695 worker_route_editor: None,
10696 progression_curve: None,
10697 };
10698 state.player = state.entities.first().cloned();
10699 state
10700 }
10701
10702 #[test]
10703 fn whisper_cancels_when_peer_walks_out_of_range() {
10704 let mut state = sample_state();
10705 state.player = state.entities.first().cloned();
10706 let mut peer = state.entities[0].clone();
10707 peer.id = 2;
10708 peer.label = "Ada".into();
10709 peer.transform.position = WorldCoord::surface(129.0, 128.0); state.entities.push(peer.clone());
10711 state.social_chat.focus_whisper(2, "Ada");
10712 state.refresh_whisper_range();
10713 assert!(matches!(
10714 state.social_chat.thread,
10715 crate::social::ChatThreadKind::Whisper { peer: 2 }
10716 ));
10717
10718 peer.transform.position = WorldCoord::surface(132.0, 128.0); state.entities[1] = peer;
10720 state.refresh_whisper_range();
10721 assert_eq!(
10722 state.social_chat.thread,
10723 crate::social::ChatThreadKind::Nearby
10724 );
10725 assert!(!state.social_chat.input_focused);
10726 }
10727
10728 #[test]
10729 fn probe_use_world_npc_beats_nearby_loot() {
10730 let mut state = sample_state();
10731 state.npcs.push(flatland_protocol::NpcView {
10732 id: "ada".into(),
10733 label: "Ada".into(),
10734 role: "broker".into(),
10735 x: 129.0,
10736 y: 128.0,
10737 building_id: None,
10738 entity_id: None,
10739 life_state: None,
10740 hp_pct: None,
10741 can_trade: true,
10742 tile_id: None,
10743 behavior_state: None,
10744 presentation_state: None,
10745 sprite_mode: None,
10746 paperdoll_ref: None,
10747 });
10748 state.ground_drops.push(flatland_protocol::GroundDropView {
10749 id: "d1".into(),
10750 template_id: "lumber".into(),
10751 quantity: 1,
10752 x: 128.5,
10753 y: 128.0,
10754 z: 0.0,
10755 tile_id: None,
10756 paperdoll_ref: None,
10757 yaw: 0.0,
10758 pitch: 0.0,
10759 roll: 0.0,
10760 draw_scale: 1.0,
10761 });
10762 let probe = state.probe_use_world();
10763 let primary = probe.primary.expect("primary");
10764 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
10765 assert_eq!(primary.id, "ada");
10766 }
10767
10768 #[test]
10769 fn probe_use_world_harvest_when_in_range() {
10770 let state = sample_state(); let probe = state.probe_use_world();
10772 assert!(
10773 probe.primary.is_none(),
10774 "oak is 2m away, out of harvest range"
10775 );
10776 assert!(probe
10777 .candidates
10778 .iter()
10779 .any(|c| c.kind == crate::UseWorldKind::Harvest));
10780
10781 let mut state = sample_state();
10782 state.resource_nodes[0].x = 129.0;
10783 let probe = state.probe_use_world();
10784 let primary = probe.primary.expect("primary");
10785 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
10786 }
10787
10788 #[test]
10789 fn probe_use_world_door_uses_building_label() {
10790 let mut state = sample_state();
10791 state.doors[0].x = 129.0;
10792 state.doors[0].y = 128.0;
10793 let probe = state.probe_use_world();
10794 let primary = probe.primary.expect("primary");
10795 assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
10796 assert_eq!(primary.label, "Broker");
10797 assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
10798 }
10799
10800 #[test]
10801 fn empty_entity_tick_preserves_welcome_snapshot() {
10802 let mut state = sample_state();
10803 state.inventory.insert("carrot".into(), 3);
10804 let delta = TickDelta {
10805 tick: 1,
10806 entities: vec![],
10807 resource_nodes: vec![],
10808 ground_drops: vec![],
10809 placed_containers: vec![],
10810 buildings: vec![],
10811 doors: vec![],
10812 interior_map: None,
10813 npcs: vec![],
10814 inventory: vec![],
10815 blueprints: vec![],
10816 world_clock: flatland_protocol::WorldClock::default(),
10817 combat: None,
10818 quest_log: vec![],
10819 hired_workers: Vec::new(),
10820 interactables: vec![],
10821 ledger: None,
10822 career: None,
10823 combat_fx: Vec::new(),
10824 };
10825
10826 state.apply_tick_fields(&delta, 1);
10827
10828 assert_eq!(state.entities.len(), 1);
10829 assert!(state.player.is_some());
10830 assert_eq!(state.inventory.get("carrot"), Some(&3));
10831 assert_eq!(state.resource_nodes.len(), 1);
10832 }
10833
10834 #[test]
10835 fn tick_preserves_world_layers_when_delta_omits_them() {
10836 let mut state = sample_state();
10837 let delta = TickDelta {
10838 tick: 1,
10839 entities: state.entities.clone(),
10840 resource_nodes: vec![],
10841 ground_drops: vec![],
10842 placed_containers: vec![],
10843 buildings: vec![],
10844 doors: vec![],
10845 interior_map: None,
10846 npcs: vec![],
10847 inventory: vec![],
10848 blueprints: vec![],
10849 world_clock: flatland_protocol::WorldClock::default(),
10850 combat: None,
10851 quest_log: vec![],
10852 hired_workers: Vec::new(),
10853 interactables: vec![],
10854 ledger: None,
10855 career: None,
10856 combat_fx: Vec::new(),
10857 };
10858
10859 state.apply_tick_fields(&delta, 1);
10860
10861 assert_eq!(state.resource_nodes.len(), 1);
10862 assert_eq!(state.buildings.len(), 1);
10863 assert_eq!(state.doors.len(), 1);
10864 }
10865
10866 #[test]
10867 fn tick_updates_resource_nodes_when_server_sends_them() {
10868 let mut state = sample_state();
10869 let delta = TickDelta {
10870 tick: 1,
10871 entities: state.entities.clone(),
10872 resource_nodes: vec![ResourceNodeView {
10873 id: "oak-1".into(),
10874 label: "Oak".into(),
10875 x: 130.0,
10876 y: 128.0,
10877 z: 0.0,
10878 item_template: "oak_log".into(),
10879 state: ResourceNodeState::Cooldown,
10880 blocking: true,
10881 blocking_radius_m: 0.8,
10882 tile_id: None,
10883 paperdoll_ref: None,
10884 yaw: 0.0,
10885 pitch: 0.0,
10886 roll: 0.0,
10887 draw_scale: 1.0,
10888 sprite_mode: None,
10889 presentation_state: None,
10890 }],
10891 buildings: vec![],
10892 doors: vec![],
10893 interior_map: None,
10894 npcs: vec![],
10895 inventory: vec![],
10896 blueprints: vec![],
10897 world_clock: flatland_protocol::WorldClock::default(),
10898 ground_drops: vec![],
10899 placed_containers: vec![],
10900 combat: None,
10901 quest_log: vec![],
10902 hired_workers: Vec::new(),
10903 interactables: vec![],
10904 ledger: None,
10905 career: None,
10906 combat_fx: Vec::new(),
10907 };
10908
10909 state.apply_tick_fields(&delta, 1);
10910
10911 assert!(matches!(
10912 state.resource_nodes[0].state,
10913 ResourceNodeState::Cooldown
10914 ));
10915 }
10916
10917 #[test]
10918 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
10919 let mut state = GameState {
10920 session_id: 1,
10921 entity_id: 1,
10922 character_id: None,
10923 tick: 0,
10924 chunk_rev: 0,
10925 content_rev: 0,
10926 publish_rev: 0,
10927 entities: vec![EntityState {
10928 id: 1,
10929 label: "You".into(),
10930 transform: Transform {
10931 position: WorldCoord::surface(4.5, 2.0),
10932 yaw: 0.0,
10933 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
10934 },
10935 vitals: None,
10936 attributes: None,
10937 skills: None,
10938 inside_building: Some("broker_hut".into()),
10939 tile_id: None,
10940 paperdoll_ref: None,
10941 presentation_state: None,
10942 sprite_mode: None,
10943 progression_xp: None,
10944 }],
10945 player: None,
10946 resource_nodes: vec![],
10947 ground_drops: vec![],
10948 placed_containers: vec![],
10949 buildings: vec![BuildingView {
10950 id: "broker_hut".into(),
10951 label: "Broker".into(),
10952 x: 158.0,
10953 y: 124.0,
10954 width_m: 8.0,
10955 depth_m: 6.0,
10956 interior_blueprint: Some("broker_hut".into()),
10957 tags: vec![],
10958 }],
10959 doors: vec![flatland_protocol::DoorView {
10960 id: "broker_hut_exit".into(),
10961 building_id: "broker_hut".into(),
10962 x: 4.3,
10963 y: 0.9,
10964 open: true,
10965 portal: Some("front".into()),
10966 }],
10967 interior_map: None,
10968 npcs: vec![flatland_protocol::NpcView {
10969 id: "ada_broker".into(),
10970 label: "Ada".into(),
10971 x: 4.5,
10972 y: 2.0,
10973 building_id: Some("broker_hut".into()),
10974 role: "broker".into(),
10975 entity_id: None,
10976 life_state: None,
10977 hp_pct: None,
10978 can_trade: true,
10979 tile_id: None,
10980 behavior_state: None,
10981 presentation_state: None,
10982 sprite_mode: None,
10983 paperdoll_ref: None,
10984 }],
10985 blueprints: vec![],
10986 world_width_m: 256.0,
10987 world_height_m: 256.0,
10988 terrain_zones: Vec::new(),
10989 z_platforms: Vec::new(),
10990 z_transitions: Vec::new(),
10991 world_clock: flatland_protocol::WorldClock::default(),
10992 inventory: std::collections::HashMap::new(),
10993 inventory_hints: std::collections::HashMap::new(),
10994 logs: VecDeque::new(),
10995 intents_sent: 0,
10996 ticks_received: 0,
10997 connected: true,
10998 disconnect_reason: None,
10999 show_stats: false,
11000 hud_log_hidden: false,
11001 show_equip_menu: false,
11002 equip_menu_index: 0,
11003 show_craft_menu: false,
11004 craft_menu_index: 0,
11005 craft_batch_quantity: 1,
11006 show_shop_menu: false,
11007 shop_catalog: None,
11008 bank_panel: None,
11009 bank_menu_index: 0,
11010 bank_ui_mode: BankUiMode::Menu,
11011 storage_panel: None,
11012 storage_menu_index: 0,
11013 storage_ui_mode: StorageUiMode::Menu,
11014 shop_tab: ShopTab::default(),
11015 shop_menu_index: 0,
11016 shop_quantity: 1,
11017 shop_trade_log: VecDeque::new(),
11018 show_npc_verb_menu: false,
11019 npc_verb_target: None,
11020 npc_verb_index: 0,
11021 player_verbs: crate::social::PlayerVerbState::default(),
11022 social_chat: crate::social::SocialChatState::default(),
11023 trade_ui: crate::social::TradeUiState::default(),
11024 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
11025 show_npc_chat: false,
11026 npc_chat: None,
11027 show_inventory_menu: false,
11028 inventory_menu_index: 0,
11029 inventory_tab: InventoryTab::OnPerson,
11030 inventory_filter: String::new(),
11031 inventory_filter_focused: false,
11032 show_move_picker: false,
11033 show_rename_prompt: false,
11034 show_worker_rename: false,
11035 rename_buffer: String::new(),
11036 move_picker_index: 0,
11037 move_picker: None,
11038 show_grant_picker: false,
11039 grant_picker_index: 0,
11040 grant_picker: None,
11041 show_destroy_picker: false,
11042 destroy_confirm_pending: false,
11043 destroy_picker: None,
11044 combat_target: None,
11045 combat_target_label: None,
11046 combat_fx: Vec::new(),
11047 in_combat: false,
11048 auto_attack: true,
11049 combat_has_los: false,
11050 attack_cd_ticks: 0,
11051 gcd_ticks: 0,
11052 weapon_ability_id: "unarmed".into(),
11053 mainhand_template_id: None,
11054 mainhand_label: None,
11055 offhand_template_id: None,
11056 offhand_label: None,
11057 mainhand_hand_slots: 1,
11058 defense: None,
11059 worn: BTreeMap::new(),
11060 carry_mass: 0.0,
11061 carry_mass_max: 0.0,
11062 encumbrance: flatland_protocol::EncumbranceState::Light,
11063 inventory_stacks: Vec::new(),
11064 keychain_stacks: Vec::new(),
11065 whisper_pouch_stacks: Vec::new(),
11066 combat_target_detail: None,
11067 statuses: Vec::new(),
11068 cast_progress: None,
11069 ability_cooldowns: Vec::new(),
11070 blocking_active: false,
11071 max_target_slots: 1,
11072 combat_slots: Vec::new(),
11073 rotation_presets: Vec::new(),
11074 known_abilities: Vec::new(),
11075 hotbar: vec![None; 9],
11076 max_abilities_per_rotation: 0,
11077 show_loadout_menu: false,
11078 show_keychain_menu: false,
11079 keychain_menu_index: 0,
11080 show_rotation_editor: false,
11081 loadout_menu_index: 0,
11082 loadout_hotbar_slot: 1,
11083 loadout_ability_index: 0,
11084 loadout_focus_presets: false,
11085 rotation_editor: RotationEditorState::default(),
11086 harvest_in_progress: false,
11087 harvest_started_at: None,
11088 pending_craft_ack: None,
11089 pending_worker_job_ack: None,
11090 quest_log: Vec::new(),
11091 interactables: Vec::new(),
11092 ledger: None,
11093 career: None,
11094 character_sheet_tab: CharacterSheetTab::Character,
11095 ledger_period: LedgerPeriod::Day,
11096 show_quest_offer: false,
11097 pending_quest_offer: None,
11098 show_quest_menu: false,
11099 quest_menu_index: 0,
11100 quest_withdraw_confirm: false,
11101 hired_workers: Vec::new(),
11102 show_workers_menu: false,
11103 workers_menu_index: 0,
11104 workers_menu_compact: false,
11105 worker_step_display: BTreeMap::new(),
11106 worker_error_display: BTreeMap::new(),
11107 show_worker_give_picker: false,
11108 worker_give_picker_index: 0,
11109 worker_give_picker: None,
11110 show_worker_give_target_picker: false,
11111 worker_give_target_picker_index: 0,
11112 worker_give_target_picker: None,
11113 show_worker_take_picker: false,
11114 worker_take_picker_index: 0,
11115 worker_take_picker: None,
11116 show_worker_teach_picker: false,
11117 worker_teach_picker_index: 0,
11118 worker_teach_picker: None,
11119 worker_route_editor: None,
11120 progression_curve: None,
11121 };
11122 state.player = state.entities.first().cloned();
11123 assert_eq!(
11124 state.nearest_interact_target().as_deref(),
11125 Some("ada_broker")
11126 );
11127 }
11128
11129 #[test]
11130 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
11131 let mut state = sample_state();
11132 state.placed_containers = vec![
11135 flatland_protocol::PlacedContainerView {
11136 id: "near".into(),
11137 template_id: "wooden_chest_small".into(),
11138 display_name: "Wooden Chest".into(),
11139 x: 130.0,
11140 y: 128.0,
11141 z: 0.0,
11142 locked: true,
11143 accessible: true,
11144 owner_character_id: None,
11145 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
11146 lock_id: None,
11147 capacity_volume: None,
11148 item_instance_id: Some(uuid::Uuid::from_u128(1)),
11149 tile_id: None,
11150 paperdoll_ref: None,
11151 worker_lodging_capacity: None,
11152 },
11153 flatland_protocol::PlacedContainerView {
11154 id: "far".into(),
11155 template_id: "wooden_chest_small".into(),
11156 display_name: "Distant Chest".into(),
11157 x: 128.0 + CONTAINER_RANGE_M + 5.0,
11158 y: 128.0,
11159 z: 0.0,
11160 locked: false,
11161 accessible: true,
11162 owner_character_id: None,
11163 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
11164 lock_id: None,
11165 capacity_volume: None,
11166 item_instance_id: Some(uuid::Uuid::from_u128(2)),
11167 tile_id: None,
11168 paperdoll_ref: None,
11169 worker_lodging_capacity: None,
11170 },
11171 ];
11172
11173 let nearby = state.nearby_containers();
11174 assert_eq!(
11175 nearby.len(),
11176 1,
11177 "far chest must not appear once out of range"
11178 );
11179 assert_eq!(nearby[0].view.id, "near");
11180 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
11181 assert!(nearby[0].rows[0].is_chest_shell);
11182
11183 state.placed_containers[0].accessible = false;
11186 let nearby = state.nearby_containers();
11187 assert_eq!(nearby.len(), 1);
11188 assert_eq!(nearby[0].rows.len(), 1);
11189 assert!(nearby[0].rows[0].is_chest_shell);
11190 }
11191
11192 #[test]
11193 fn chest_pickup_destinations_offer_person_and_worn_bag() {
11194 let mut state = sample_state();
11195 let back_id = uuid::Uuid::from_u128(42);
11196 state.worn.insert(
11197 BodySlot::Back,
11198 flatland_protocol::ItemStack {
11199 template_id: "travel_backpack".into(),
11200 quantity: 1,
11201 item_instance_id: Some(back_id),
11202 props: Default::default(),
11203 status_bindings: Vec::new(),
11204 contents: Vec::new(),
11205 display_name: Some("Travel Backpack".into()),
11206 category: Some("container".into()),
11207 base_mass: Some(2.5),
11208 base_volume: Some(12.0),
11209 capacity_volume: Some(80.0),
11210 stackable: Some(false),
11211 world_placeable: Some(false),
11212 worker_lodging_capacity: None,
11213 equip_slot: None,
11214 armor_physical: None,
11215 resists: vec![],
11216 hand_slots: None,
11217 },
11218 );
11219 let opts = state.chest_pickup_destinations("chest-1");
11220 assert!(opts.iter().any(|o| matches!(
11221 &o.kind,
11222 MoveOptionKind::PickupPlaced {
11223 nest_parent_instance_id: None,
11224 ..
11225 }
11226 )));
11227 assert!(opts.iter().any(|o| matches!(
11228 &o.kind,
11229 MoveOptionKind::PickupPlaced {
11230 nest_parent_instance_id: Some(id),
11231 ..
11232 } if *id == back_id
11233 )));
11234 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
11235 }
11236
11237 #[test]
11238 fn placed_container_public_label_hides_owner_custom_name() {
11239 let owner = uuid::Uuid::from_u128(99);
11240 let mut state = sample_state();
11241 state.character_id = Some(uuid::Uuid::from_u128(1));
11242 state.inventory_hints.insert(
11243 "wooden_chest_medium".into(),
11244 InventoryHint {
11245 display_name: "Medium Wooden Chest".into(),
11246 category: "container".into(),
11247 base_mass: None,
11248 base_volume: None,
11249 capacity_volume: None,
11250 stackable: false,
11251 },
11252 );
11253 let chest = flatland_protocol::PlacedContainerView {
11254 id: "c1".into(),
11255 template_id: "wooden_chest_medium".into(),
11256 display_name: "Barry's Loot #a3f2".into(),
11257 x: 128.0,
11258 y: 128.0,
11259 z: 0.0,
11260 locked: false,
11261 accessible: true,
11262 owner_character_id: Some(owner),
11263 contents: vec![],
11264 lock_id: None,
11265 capacity_volume: None,
11266 item_instance_id: None,
11267 tile_id: None,
11268 paperdoll_ref: None,
11269 worker_lodging_capacity: None,
11270 };
11271 assert_eq!(
11272 state.placed_container_public_label(&chest),
11273 "Medium Wooden Chest"
11274 );
11275 state.character_id = Some(owner);
11276 assert_eq!(
11277 state.placed_container_public_label(&chest),
11278 "Barry's Loot #a3f2"
11279 );
11280 }
11281
11282 #[test]
11283 fn location_context_lists_nearby_resource_node() {
11284 let mut state = sample_state();
11285 state.player = state.entities.first().cloned();
11286 state.resource_nodes[0].x = 128.2;
11287 state.resource_nodes[0].y = 128.0;
11288 let lines = state.location_context_lines();
11289 assert!(
11290 lines
11291 .iter()
11292 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
11293 "expected resource node in context: {:?}",
11294 lines
11295 );
11296 }
11297
11298 #[test]
11299 fn quest_board_usable_within_board_radius() {
11300 let mut state = sample_state();
11301 state.player = state.entities.first().cloned();
11302 state.interactables = vec![flatland_protocol::InteractableView {
11303 id: "board-1".into(),
11304 kind: "quest_board".into(),
11305 label: "Town Quest Board".into(),
11306 x: 130.5,
11307 y: 128.0,
11308 z: 0.0,
11309 board_id: Some("starter_town_board".into()),
11310 }];
11311 assert_eq!(
11313 state.nearest_interact_target().as_deref(),
11314 Some("board-1"),
11315 "quest board should be selectable at ~2.5m"
11316 );
11317 let lines = state.location_context_lines();
11318 assert!(
11319 lines
11320 .iter()
11321 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
11322 "HUD should advertise f when board is in range: {:?}",
11323 lines
11324 );
11325 }
11326
11327 #[test]
11328 fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
11329 let mut state = sample_state();
11330 state.worn.insert(
11331 BodySlot::Back,
11332 flatland_protocol::ItemStack {
11333 template_id: "travel_backpack".into(),
11334 quantity: 1,
11335 item_instance_id: Some(uuid::Uuid::from_u128(3)),
11336 props: Default::default(),
11337 status_bindings: Vec::new(),
11338 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
11339 display_name: None,
11340 category: None,
11341 base_mass: None,
11342 base_volume: None,
11343 capacity_volume: None,
11344 stackable: None,
11345 world_placeable: None,
11346 worker_lodging_capacity: None,
11347 equip_slot: None,
11348 armor_physical: None,
11349 resists: vec![],
11350 hand_slots: None,
11351 },
11352 );
11353 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
11354 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11355 id: "chest-1".into(),
11356 template_id: "wooden_chest_small".into(),
11357 display_name: "Wooden Chest".into(),
11358 x: 129.0,
11359 y: 128.0,
11360 z: 0.0,
11361 locked: false,
11362 accessible: true,
11363 owner_character_id: None,
11364 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
11365 lock_id: None,
11366 capacity_volume: None,
11367 item_instance_id: Some(uuid::Uuid::from_u128(4)),
11368 tile_id: None,
11369 paperdoll_ref: None,
11370 worker_lodging_capacity: None,
11371 }];
11372
11373 state.inventory_tab = InventoryTab::OnPerson;
11374 let rows = state.inventory_selectable_rows();
11375 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
11376 assert_eq!(
11377 sections,
11378 vec![
11379 InventorySection::Worn, InventorySection::Worn, InventorySection::Person, ]
11383 );
11384 assert_eq!(rows[0].stack.template_id, "travel_backpack");
11385 assert!(rows[0].is_equip_shell);
11386 assert_eq!(rows[1].stack.template_id, "iron_ore");
11387 assert_eq!(rows[1].depth, 1);
11388 assert_eq!(rows[2].stack.template_id, "lumber");
11389
11390 let lines = state.inventory_browser_lines();
11391 assert!(lines.iter().any(|l| matches!(
11392 l,
11393 InventoryBrowserLine::Section(s) if s.contains("Worn")
11394 )));
11395 assert!(lines.iter().any(|l| matches!(
11396 l,
11397 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
11398 || text.contains("backpack")
11399 )));
11400 assert!(!lines.iter().any(|l| matches!(
11401 l,
11402 InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
11403 )));
11404
11405 state.inventory_tab = InventoryTab::Nearby;
11406 let nearby_rows = state.inventory_selectable_rows();
11407 assert_eq!(nearby_rows.len(), 2);
11408 assert!(nearby_rows[0].is_chest_shell);
11409 assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
11410 let nearby_lines = state.inventory_browser_lines();
11411 assert!(nearby_lines.iter().any(|l| matches!(
11412 l,
11413 InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
11414 )));
11415 }
11416
11417 #[test]
11418 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
11419 let mut state = sample_state();
11420 let back_id = uuid::Uuid::from_u128(5);
11421 state.worn.insert(
11422 BodySlot::Back,
11423 flatland_protocol::ItemStack {
11424 template_id: "travel_backpack".into(),
11425 quantity: 1,
11426 item_instance_id: Some(back_id),
11427 props: Default::default(),
11428 status_bindings: Vec::new(),
11429 contents: Vec::new(),
11430 display_name: None,
11431 category: Some("container".into()),
11432 base_mass: None,
11433 base_volume: None,
11434 capacity_volume: Some(80.0),
11435 stackable: None,
11436 world_placeable: None,
11437 worker_lodging_capacity: None,
11438 equip_slot: None,
11439 armor_physical: None,
11440 resists: vec![],
11441 hand_slots: None,
11442 },
11443 );
11444 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11445 id: "chest-1".into(),
11446 template_id: "wooden_chest_small".into(),
11447 display_name: "Wooden Chest".into(),
11448 x: 129.0,
11449 y: 128.0,
11450 z: 0.0,
11451 locked: false,
11452 accessible: true,
11453 owner_character_id: None,
11454 contents: Vec::new(),
11455 lock_id: None,
11456 capacity_volume: None,
11457 item_instance_id: Some(uuid::Uuid::from_u128(6)),
11458 tile_id: None,
11459 paperdoll_ref: None,
11460 worker_lodging_capacity: None,
11461 }];
11462
11463 let opts = state.move_destinations_for(
11466 &flatland_protocol::InventoryLocation::Root,
11467 None,
11468 None,
11469 "lumber",
11470 );
11471 assert!(!opts.iter().any(|o| matches!(
11472 &o.kind,
11473 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
11474 )));
11475 assert!(opts.iter().any(|o| matches!(
11476 &o.kind,
11477 MoveOptionKind::Move { location, parent_instance_id, .. }
11478 if *location == flatland_protocol::InventoryLocation::Worn {
11479 slot: BodySlot::Back,
11480 } && *parent_instance_id == Some(back_id)
11481 )));
11482 assert!(opts.iter().any(|o| matches!(
11483 &o.kind,
11484 MoveOptionKind::Move { location, .. }
11485 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
11486 )));
11487 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
11488 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
11489
11490 let from_backpack = flatland_protocol::InventoryLocation::Worn {
11494 slot: BodySlot::Back,
11495 };
11496 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
11497 assert!(!opts.iter().any(|o| matches!(
11498 &o.kind,
11499 MoveOptionKind::Move { location, parent_instance_id, .. }
11500 if *location == from_backpack && *parent_instance_id == Some(back_id)
11501 )));
11502 assert!(opts.iter().any(|o| matches!(
11503 &o.kind,
11504 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
11505 )));
11506 }
11507
11508 #[test]
11509 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
11510 let mut state = sample_state();
11511 state.worn.insert(
11514 BodySlot::Waist,
11515 flatland_protocol::ItemStack {
11516 template_id: "simple_belt".into(),
11517 quantity: 1,
11518 item_instance_id: Some(uuid::Uuid::from_u128(10)),
11519 props: Default::default(),
11520 status_bindings: Vec::new(),
11521 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
11522 display_name: None,
11523 category: Some("container".into()),
11524 base_mass: None,
11525 base_volume: None,
11526 capacity_volume: None,
11527 stackable: None,
11528 world_placeable: None,
11529 worker_lodging_capacity: None,
11530 equip_slot: None,
11531 armor_physical: None,
11532 resists: vec![],
11533 hand_slots: None,
11534 },
11535 );
11536 state.worn.insert(
11537 BodySlot::Head,
11538 flatland_protocol::ItemStack {
11539 template_id: "cloth_cap".into(),
11540 quantity: 1,
11541 item_instance_id: Some(uuid::Uuid::from_u128(11)),
11542 props: Default::default(),
11543 status_bindings: Vec::new(),
11544 contents: Vec::new(),
11545 display_name: None,
11546 category: Some("armor".into()),
11547 base_mass: None,
11548 base_volume: None,
11549 capacity_volume: None,
11550 stackable: None,
11551 world_placeable: None,
11552 worker_lodging_capacity: None,
11553 equip_slot: None,
11554 armor_physical: None,
11555 resists: vec![],
11556 hand_slots: None,
11557 },
11558 );
11559 state.worn.insert(
11560 BodySlot::Back,
11561 flatland_protocol::ItemStack {
11562 template_id: "travel_backpack".into(),
11563 quantity: 1,
11564 item_instance_id: Some(uuid::Uuid::from_u128(12)),
11565 props: Default::default(),
11566 status_bindings: Vec::new(),
11567 contents: Vec::new(),
11568 display_name: None,
11569 category: Some("container".into()),
11570 base_mass: None,
11571 base_volume: None,
11572 capacity_volume: None,
11573 stackable: None,
11574 world_placeable: None,
11575 worker_lodging_capacity: None,
11576 equip_slot: None,
11577 armor_physical: None,
11578 resists: vec![],
11579 hand_slots: None,
11580 },
11581 );
11582
11583 let rows = state.worn_rows();
11584 assert_eq!(rows.len(), 4);
11586 assert_eq!(rows[0].stack.template_id, "cloth_cap");
11587 assert!(rows[0].is_equip_shell);
11588 assert_eq!(rows[1].stack.template_id, "travel_backpack");
11589 assert!(rows[1].is_equip_shell);
11590 assert_eq!(rows[2].stack.template_id, "simple_belt");
11591 assert!(rows[2].is_equip_shell);
11592 assert_eq!(rows[3].stack.template_id, "leather_pouch");
11593 assert_eq!(rows[3].depth, 1);
11594 assert!(!rows[3].is_equip_shell);
11595 }
11596
11597 #[test]
11598 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
11599 let mut state = sample_state();
11600 state.worn.insert(
11601 BodySlot::Waist,
11602 flatland_protocol::ItemStack {
11603 template_id: "simple_belt".into(),
11604 quantity: 1,
11605 item_instance_id: Some(uuid::Uuid::from_u128(20)),
11606 props: Default::default(),
11607 status_bindings: Vec::new(),
11608 contents: Vec::new(),
11609 display_name: Some("Simple Belt".into()),
11610 category: Some("container".into()),
11611 base_mass: None,
11612 base_volume: None,
11613 capacity_volume: None,
11614 stackable: None,
11615 world_placeable: None,
11616 worker_lodging_capacity: None,
11617 equip_slot: None,
11618 armor_physical: None,
11619 resists: vec![],
11620 hand_slots: None,
11621 },
11622 );
11623 state.worn.insert(
11624 BodySlot::Head,
11625 flatland_protocol::ItemStack {
11626 template_id: "cloth_cap".into(),
11627 quantity: 1,
11628 item_instance_id: Some(uuid::Uuid::from_u128(21)),
11629 props: Default::default(),
11630 status_bindings: Vec::new(),
11631 contents: Vec::new(),
11632 display_name: Some("Cloth Cap".into()),
11633 category: Some("armor".into()),
11634 base_mass: None,
11635 base_volume: None,
11636 capacity_volume: None,
11637 stackable: None,
11638 world_placeable: None,
11639 worker_lodging_capacity: None,
11640 equip_slot: None,
11641 armor_physical: None,
11642 resists: vec![],
11643 hand_slots: None,
11644 },
11645 );
11646
11647 let opts = state.move_destinations_for(
11648 &flatland_protocol::InventoryLocation::Root,
11649 None,
11650 None,
11651 "leather_pouch",
11652 );
11653 assert!(
11654 opts.iter().any(|o| matches!(
11655 &o.kind,
11656 MoveOptionKind::Move { location, .. }
11657 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
11658 )),
11659 "belt loop must be offered when moving a pouch"
11660 );
11661 assert!(
11662 !opts.iter().any(|o| matches!(
11663 &o.kind,
11664 MoveOptionKind::Move { location, .. }
11665 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
11666 )),
11667 "armor slots can't hold other items and must not appear as move destinations"
11668 );
11669 let belt_opt = opts
11670 .iter()
11671 .find(|o| matches!(
11672 &o.kind,
11673 MoveOptionKind::Move { location, .. }
11674 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
11675 ))
11676 .unwrap();
11677 assert!(belt_opt.label.contains("belt loop"));
11678
11679 let opts = state.move_destinations_for(
11680 &flatland_protocol::InventoryLocation::Root,
11681 None,
11682 None,
11683 "lumber",
11684 );
11685 assert!(
11686 !opts.iter().any(|o| o.label.contains("belt loop")),
11687 "loose materials must not target the belt shell — only nested pouches"
11688 );
11689 }
11690
11691 #[test]
11692 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
11693 let mut state = sample_state();
11694 let belt_id = uuid::Uuid::from_u128(30);
11695 let pouch_id = uuid::Uuid::from_u128(31);
11696 state.worn.insert(
11697 BodySlot::Waist,
11698 flatland_protocol::ItemStack {
11699 template_id: "simple_belt".into(),
11700 quantity: 1,
11701 item_instance_id: Some(belt_id),
11702 props: Default::default(),
11703 status_bindings: Vec::new(),
11704 world_placeable: None,
11705 worker_lodging_capacity: None,
11706 equip_slot: None,
11707 armor_physical: None,
11708 resists: vec![],
11709 hand_slots: None,
11710 contents: vec![flatland_protocol::ItemStack {
11711 template_id: "dimensional_pouch".into(),
11712 quantity: 1,
11713 item_instance_id: Some(pouch_id),
11714 props: Default::default(),
11715 status_bindings: Vec::new(),
11716 contents: Vec::new(),
11717 display_name: Some("Dimensional Pouch".into()),
11718 category: Some("container".into()),
11719 base_mass: None,
11720 base_volume: None,
11721 capacity_volume: Some(200.0),
11722 stackable: None,
11723 world_placeable: None,
11724 worker_lodging_capacity: None,
11725 equip_slot: None,
11726 armor_physical: None,
11727 resists: vec![],
11728 hand_slots: None,
11729 }],
11730 display_name: Some("Simple Belt".into()),
11731 category: Some("container".into()),
11732 base_mass: None,
11733 base_volume: None,
11734 capacity_volume: None,
11735 stackable: None,
11736 },
11737 );
11738
11739 let opts = state.move_destinations_for(
11740 &flatland_protocol::InventoryLocation::Root,
11741 None,
11742 None,
11743 "iron_ore",
11744 );
11745 assert!(
11746 opts.iter().any(|o| matches!(
11747 &o.kind,
11748 MoveOptionKind::Move {
11749 location,
11750 parent_instance_id,
11751 ..
11752 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
11753 && *parent_instance_id == Some(pouch_id)
11754 )),
11755 "dimensional pouch clipped on belt must accept loose items"
11756 );
11757 assert!(
11758 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
11759 "destination label should name the pouch"
11760 );
11761 }
11762
11763 #[test]
11764 fn container_volume_label_on_placed_chest_shell() {
11765 let mut state = sample_state();
11766 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11767 id: "chest-1".into(),
11768 template_id: "wooden_chest_small".into(),
11769 display_name: "Camp Chest".into(),
11770 worker_lodging_capacity: None,
11771 x: 129.0,
11772 y: 128.0,
11773 z: 0.0,
11774 locked: false,
11775 accessible: true,
11776 owner_character_id: None,
11777 contents: vec![flatland_protocol::ItemStack {
11778 template_id: "iron_ore".into(),
11779 quantity: 2,
11780 item_instance_id: None,
11781 props: Default::default(),
11782 status_bindings: Vec::new(),
11783 contents: Vec::new(),
11784 display_name: None,
11785 category: None,
11786 base_mass: None,
11787 base_volume: Some(2.0),
11788 capacity_volume: None,
11789 stackable: None,
11790 world_placeable: None,
11791 worker_lodging_capacity: None,
11792 equip_slot: None,
11793 armor_physical: None,
11794 resists: vec![],
11795 hand_slots: None,
11796 }],
11797 lock_id: None,
11798 capacity_volume: Some(60.0),
11799 item_instance_id: Some(uuid::Uuid::from_u128(4)),
11800 tile_id: None,
11801 paperdoll_ref: None,
11802 }];
11803 let nearby = state.nearby_containers();
11804 let label = state.container_volume_label(&nearby[0].rows[0]);
11805 assert!(
11806 label.contains("vol 4/60"),
11807 "expected used/cap in label, got {label}"
11808 );
11809 assert!(
11810 label.contains("56 free"),
11811 "expected free space, got {label}"
11812 );
11813 }
11814
11815 #[test]
11816 fn key_pair_chest_label_from_placed_lock_id() {
11817 let mut state = sample_state();
11818 let owner = uuid::Uuid::from_u128(77);
11819 state.character_id = Some(owner);
11820 let lock = uuid::Uuid::from_u128(99).to_string();
11821 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11822 id: "chest-1".into(),
11823 template_id: "wooden_chest_small".into(),
11824 display_name: "Barry's Loot #a3f2".into(),
11825 x: 129.0,
11826 y: 128.0,
11827 z: 0.0,
11828 locked: true,
11829 accessible: true,
11830 owner_character_id: Some(owner),
11831 contents: Vec::new(),
11832 lock_id: Some(lock.clone()),
11833 capacity_volume: None,
11834 item_instance_id: Some(uuid::Uuid::from_u128(4)),
11835 tile_id: None,
11836 paperdoll_ref: None,
11837 worker_lodging_capacity: None,
11838 }];
11839 let key_id = uuid::Uuid::from_u128(5);
11840 let key = flatland_protocol::ItemStack {
11841 template_id: KEY_TEMPLATE.into(),
11842 quantity: 1,
11843 item_instance_id: Some(key_id),
11844 props: BTreeMap::from([
11845 (PROP_OPENS_LOCK_ID.into(), lock),
11846 (
11847 PROP_OPENS_CONTAINER_NAME.into(),
11848 "Barry's Loot #a3f2".into(),
11849 ),
11850 ]),
11851 status_bindings: Vec::new(),
11852 contents: Vec::new(),
11853 display_name: Some("Container Key".into()),
11854 category: Some("key".into()),
11855 base_mass: None,
11856 base_volume: None,
11857 capacity_volume: None,
11858 stackable: None,
11859 world_placeable: None,
11860 worker_lodging_capacity: None,
11861 equip_slot: None,
11862 armor_physical: None,
11863 resists: vec![],
11864 hand_slots: None,
11865 };
11866 state.inventory_stacks = vec![key.clone()];
11867 assert_eq!(
11868 state.key_pair_chest_label(&key).as_deref(),
11869 Some("Barry's Loot #a3f2")
11870 );
11871 assert!(state.key_drop_blocked(&key));
11872 }
11873
11874 #[test]
11875 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
11876 let mut state = sample_state();
11877 let lock = uuid::Uuid::from_u128(101).to_string();
11878 let key = flatland_protocol::ItemStack {
11879 template_id: KEY_TEMPLATE.into(),
11880 quantity: 1,
11881 item_instance_id: Some(uuid::Uuid::from_u128(7)),
11882 props: BTreeMap::from([
11883 (PROP_OPENS_LOCK_ID.into(), lock),
11884 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
11885 ]),
11886 status_bindings: Vec::new(),
11887 contents: Vec::new(),
11888 display_name: None,
11889 category: Some("key".into()),
11890 base_mass: None,
11891 base_volume: None,
11892 capacity_volume: None,
11893 stackable: None,
11894 world_placeable: None,
11895 worker_lodging_capacity: None,
11896 equip_slot: None,
11897 armor_physical: None,
11898 resists: vec![],
11899 hand_slots: None,
11900 };
11901 state.placed_containers.clear();
11902 assert_eq!(
11903 state.key_pair_chest_label(&key).as_deref(),
11904 Some("Camp Stash")
11905 );
11906 }
11907
11908 #[test]
11909 fn key_drop_allowed_when_paired_chest_unlocked() {
11910 let mut state = sample_state();
11911 let lock = uuid::Uuid::from_u128(100).to_string();
11912 let key_id = uuid::Uuid::from_u128(6);
11913 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11914 id: "chest-1".into(),
11915 template_id: "wooden_chest_small".into(),
11916 display_name: "Camp Chest".into(),
11917 x: 129.0,
11918 y: 128.0,
11919 z: 0.0,
11920 locked: false,
11921 accessible: true,
11922 owner_character_id: None,
11923 contents: Vec::new(),
11924 lock_id: Some(lock.clone()),
11925 capacity_volume: None,
11926 item_instance_id: None,
11927 tile_id: None,
11928 paperdoll_ref: None,
11929 worker_lodging_capacity: None,
11930 }];
11931 let key = flatland_protocol::ItemStack {
11932 template_id: KEY_TEMPLATE.into(),
11933 quantity: 1,
11934 item_instance_id: Some(key_id),
11935 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
11936 status_bindings: Vec::new(),
11937 contents: Vec::new(),
11938 display_name: None,
11939 category: Some("key".into()),
11940 base_mass: None,
11941 base_volume: None,
11942 capacity_volume: None,
11943 stackable: None,
11944 world_placeable: None,
11945 worker_lodging_capacity: None,
11946 equip_slot: None,
11947 armor_physical: None,
11948 resists: vec![],
11949 hand_slots: None,
11950 };
11951 state.inventory_stacks = vec![key.clone()];
11952 assert!(!state.key_drop_blocked(&key));
11953 let opts = state.move_destinations_for(
11954 &flatland_protocol::InventoryLocation::Root,
11955 None,
11956 Some(key_id),
11957 KEY_TEMPLATE,
11958 );
11959 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
11960 }
11961
11962 #[test]
11963 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
11964 use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
11965
11966 let mut state = sample_state();
11967 let curve = ProgressionCurve::default();
11968 let bootstrap = ProgressionXp::bootstrap_new(
11969 curve.baseline_display,
11970 curve.xp_base,
11971 curve.xp_growth,
11972 );
11973 let mut fresh = bootstrap.clone();
11974 fresh.strength += 0.08;
11975 if let Some(player) = state.player.as_mut() {
11976 player.progression_xp = Some(bootstrap);
11977 }
11978
11979 let combat = CombatHud {
11980 progression_xp: Some(fresh.clone()),
11981 progression_baseline: curve.baseline_display,
11982 progression_xp_base: curve.xp_base,
11983 progression_xp_growth: curve.xp_growth,
11984 attributes: state.player.as_ref().and_then(|p| p.attributes),
11985 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
11986 ..CombatHud::default()
11987 };
11988 state.apply_combat_hud(&combat);
11989
11990 let xp = state
11991 .player
11992 .as_ref()
11993 .and_then(|p| p.progression_xp.as_ref())
11994 .expect("xp");
11995 assert!((xp.strength - fresh.strength).abs() < 0.001);
11996 assert!(state.progression_curve.is_some());
11997 }
11998
11999 #[test]
12000 fn combat_hud_syncs_known_abilities_and_hotbar() {
12001 use flatland_protocol::CombatHud;
12002
12003 let mut state = sample_state();
12004 let combat = CombatHud {
12005 known_abilities: vec!["unarmed".into(), "fireball".into()],
12006 hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
12007 max_abilities_per_rotation: 4,
12008 ability_id: "short_sword_slash".into(),
12009 ..CombatHud::default()
12010 };
12011 state.apply_combat_hud(&combat);
12012
12013 assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
12014 assert_eq!(state.hotbar_ability(1), Some("fireball"));
12015 assert_eq!(state.hotbar_ability(2), None);
12016 assert_eq!(state.hotbar_ability(3), Some("unarmed"));
12017 assert_eq!(state.max_abilities_per_rotation, 4);
12018 let choices = state.loadout_ability_choices();
12019 assert!(choices.iter().any(|a| a == "short_sword_slash"));
12020 assert!(choices.iter().any(|a| a == "fireball"));
12021 }
12022
12023 #[test]
12024 fn loadout_hotbar_choices_include_inventory_consumables() {
12025 let mut state = sample_state();
12026 state.known_abilities = vec!["unarmed".into()];
12027 state.weapon_ability_id = "unarmed".into();
12028 state.inventory_stacks = vec![flatland_protocol::ItemStack {
12029 template_id: "bottle_of_water".into(),
12030 quantity: 3,
12031 item_instance_id: Some(uuid::Uuid::from_u128(9)),
12032 display_name: Some("Bottle of Water".into()),
12033 category: Some("consumable".into()),
12034 ..Default::default()
12035 }];
12036 state.inventory.insert("bottle_of_water".into(), 3);
12037 state.inventory_hints.insert(
12038 "bottle_of_water".into(),
12039 InventoryHint {
12040 display_name: "Bottle of Water".into(),
12041 category: "consumable".into(),
12042 ..Default::default()
12043 },
12044 );
12045
12046 let choices = state.loadout_hotbar_choices();
12047 assert!(choices.iter().any(|c| c.binding == "unarmed"));
12048 let water = choices
12049 .iter()
12050 .find(|c| c.binding == "item:bottle_of_water")
12051 .expect("water binding");
12052 assert_eq!(water.meta.as_deref(), Some("use"));
12053 assert!(water.label.contains("Water"));
12054 assert_eq!(
12055 state.hotbar_slot_label(1),
12056 None,
12057 "unbound until set"
12058 );
12059 state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
12060 assert_eq!(
12061 state.hotbar_slot_label(5).as_deref(),
12062 Some("Bottle of Water×3")
12063 );
12064 }
12065
12066 #[test]
12067 fn loose_consumable_move_picker_offers_use_and_storage() {
12068 let mut state = sample_state();
12069 let inst = uuid::Uuid::from_u128(77);
12070 state.inventory_stacks = vec![flatland_protocol::ItemStack {
12071 template_id: "carrot".into(),
12072 quantity: 2,
12073 item_instance_id: Some(inst),
12074 props: Default::default(),
12075 status_bindings: Vec::new(),
12076 contents: Vec::new(),
12077 display_name: Some("Wild Carrot".into()),
12078 category: Some("consumable".into()),
12079 base_mass: None,
12080 base_volume: None,
12081 capacity_volume: None,
12082 stackable: Some(true),
12083 world_placeable: None,
12084 worker_lodging_capacity: None,
12085 equip_slot: None,
12086 armor_physical: None,
12087 resists: vec![],
12088 hand_slots: None,
12089 }];
12090 state.inventory_hints.insert(
12091 "carrot".into(),
12092 InventoryHint {
12093 display_name: "Wild Carrot".into(),
12094 category: "consumable".into(),
12095 base_mass: Some(0.15),
12096 base_volume: Some(0.3),
12097 capacity_volume: None,
12098 stackable: true,
12099 },
12100 );
12101 state.show_inventory_menu = true;
12102 state.inventory_menu_index = 0;
12103
12104 let row = state.inventory_selected_row().expect("carrot row");
12105 let mut options = state.move_destinations_for(
12106 &row.from,
12107 row.from_parent_instance_id,
12108 row.stack.item_instance_id,
12109 &row.stack.template_id,
12110 );
12111 if row.from == flatland_protocol::InventoryLocation::Root
12112 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
12113 {
12114 options.insert(
12115 0,
12116 MoveOption {
12117 label: "Use (eat / drink)".into(),
12118 kind: MoveOptionKind::Use,
12119 },
12120 );
12121 }
12122
12123 assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
12124 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
12125 assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
12126 }
12127
12128 #[test]
12129 fn inventory_category_group_order_is_stable() {
12130 assert_eq!(inventory_category_group("weapon").0, "Weapons");
12131 assert_eq!(inventory_category_group("armor").0, "Armor");
12132 assert_eq!(inventory_category_group("consumable").0, "Consumables");
12133 assert_eq!(inventory_category_group("resource").0, "Resources");
12134 assert_eq!(inventory_category_group("container").0, "Containers");
12135 assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
12136 assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
12137 }
12138
12139 #[test]
12140 fn page_list_index_clamps_without_wrap() {
12141 assert_eq!(page_list_index(0, -1, 25), 0);
12142 assert_eq!(page_list_index(0, 1, 25), 10);
12143 assert_eq!(page_list_index(12, 1, 25), 22);
12144 assert_eq!(page_list_index(22, 1, 25), 24);
12145 assert_eq!(page_list_index(5, 1, 0), 0);
12146 assert_eq!(page_list_index(3, -1, 8), 0);
12147 }
12148
12149 #[test]
12150 fn inventory_filter_hides_non_matching_person_items() {
12151 let mut state = sample_state();
12152 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
12153 sword.display_name = Some("Iron Sword".into());
12154 sword.category = Some("weapon".into());
12155 let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
12156 herb.display_name = Some("Wild Herb".into());
12157 herb.category = Some("consumable".into());
12158 state.inventory_stacks = vec![sword, herb];
12159 state.inventory_tab = InventoryTab::OnPerson;
12160 state.inventory_filter = "sword".into();
12161
12162 let rows = state.inventory_selectable_rows();
12163 assert_eq!(rows.len(), 1);
12164 assert_eq!(rows[0].stack.template_id, "iron_sword");
12165
12166 let lines = state.inventory_browser_lines();
12167 assert!(lines.iter().any(|l| matches!(
12168 l,
12169 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
12170 )));
12171 assert!(!lines.iter().any(|l| matches!(
12172 l,
12173 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
12174 )));
12175 }
12176
12177 #[test]
12178 fn inventory_person_rows_group_by_category() {
12179 let mut state = sample_state();
12180 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
12181 sword.category = Some("weapon".into());
12182 sword.display_name = Some("Iron Sword".into());
12183 let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
12184 ore.category = Some("resource".into());
12185 ore.display_name = Some("Iron Ore".into());
12186 let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
12187 potion.category = Some("consumable".into());
12188 potion.display_name = Some("Health Potion".into());
12189 state.inventory_stacks = vec![ore, potion, sword];
12190 state.inventory_tab = InventoryTab::OnPerson;
12191
12192 let lines = state.inventory_browser_lines();
12193 let labels: Vec<&str> = lines
12194 .iter()
12195 .filter_map(|l| match l {
12196 InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
12197 _ => None,
12198 })
12199 .collect();
12200 assert!(
12201 labels.iter().any(|s| s.contains("Weapons")),
12202 "expected Weapons group: {labels:?}"
12203 );
12204 assert!(labels.iter().any(|s| s.contains("Consumables")));
12205 assert!(labels.iter().any(|s| s.contains("Resources")));
12206
12207 let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
12208 let consumable_pos = labels.iter().position(|s| s.contains("Consumables")).unwrap();
12209 let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
12210 assert!(weapon_pos < consumable_pos);
12211 assert!(consumable_pos < resource_pos);
12212 }
12213
12214 #[test]
12215 fn inventory_tab_cycle_resets_selection() {
12216 let mut state = sample_state();
12217 state.inventory_tab = InventoryTab::OnPerson;
12218 state.inventory_menu_index = 3;
12219 state.inventory_tab = state.inventory_tab.cycle(true);
12220 assert_eq!(state.inventory_tab, InventoryTab::Nearby);
12221 assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
12223 assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
12224 assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
12225 assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
12226 }
12227
12228 #[test]
12229 fn parse_bank_copper_amount_blank_and_zero_mean_all() {
12230 assert_eq!(parse_bank_copper_amount(""), Some(0));
12231 assert_eq!(parse_bank_copper_amount(" "), Some(0));
12232 assert_eq!(parse_bank_copper_amount("0"), Some(0));
12233 assert_eq!(parse_bank_copper_amount("250"), Some(250));
12234 assert_eq!(parse_bank_copper_amount("nope"), None);
12235 }
12236
12237 #[test]
12238 fn parse_storage_quantity_blank_and_zero_mean_all() {
12239 assert_eq!(parse_storage_quantity(""), Some(None));
12240 assert_eq!(parse_storage_quantity(" "), Some(None));
12241 assert_eq!(parse_storage_quantity("0"), Some(None));
12242 assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
12243 assert_eq!(parse_storage_quantity("nope"), None);
12244 }
12245
12246 #[test]
12247 fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
12248 assert!(worker_error_is_hud_noise("path stuck — repathing"));
12249 assert!(worker_error_is_hud_noise("path stuck — nudged clear, repathing"));
12250 assert!(worker_error_is_hud_noise("returned to lodging after path failures"));
12251 assert!(!worker_error_is_hud_noise(
12253 "path stuck — no lodging to reset to"
12254 ));
12255 }
12256}