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 if self
3994 .hired_workers
3995 .iter()
3996 .any(|w| w.entity_id == entity.id)
3997 {
3998 continue;
3999 }
4000 consider(
4001 distance(
4002 px,
4003 py,
4004 entity.transform.position.x,
4005 entity.transform.position.y,
4006 ),
4007 INTERACTION_RADIUS_M,
4008 Kind::Player,
4009 entity.id.to_string(),
4010 );
4011 }
4012
4013 for door in &self.doors {
4014 if let Some(ref bid) = inside {
4015 if door.building_id != *bid {
4016 continue;
4017 }
4018 let is_exit = door.portal.is_some();
4019 let max = if is_exit {
4020 INTERACTION_RADIUS_M
4021 } else {
4022 DOOR_INTERACTION_RADIUS_M
4023 };
4024 let kind = if is_exit {
4025 Kind::ExitDoor
4026 } else {
4027 Kind::EnterDoor
4028 };
4029 consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
4030 continue;
4031 }
4032 consider(
4033 distance(px, py, door.x, door.y),
4034 DOOR_INTERACTION_RADIUS_M,
4035 Kind::EnterDoor,
4036 door.id.clone(),
4037 );
4038 }
4039
4040 if inside.is_none() {
4041 for inter in &self.interactables {
4042 if inter.kind == "quest_board" {
4043 consider(
4044 distance(px, py, inter.x, inter.y),
4045 QUEST_BOARD_INTERACTION_RADIUS_M,
4046 Kind::QuestBoard,
4047 inter.id.clone(),
4048 );
4049 }
4050 }
4051 for building in &self.buildings {
4052 if !building.tags.iter().any(|t| t == "well") {
4053 continue;
4054 }
4055 consider(
4056 distance(px, py, building.x, building.y),
4057 INTERACTION_RADIUS_M,
4058 Kind::Well,
4059 building.id.clone(),
4060 );
4061 }
4062 if self.in_shallow_water() {
4063 consider(
4064 0.0,
4065 INTERACTION_RADIUS_M,
4066 Kind::Water,
4067 "water_source".into(),
4068 );
4069 }
4070 }
4071
4072 best.map(|(_, _, id)| id)
4073 }
4074
4075 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
4077 if self.effective_inside_building().is_some() {
4078 return None;
4079 }
4080 let (px, py) = self.player_position();
4081 self.interactables
4082 .iter()
4083 .filter(|i| i.kind == "quest_board")
4084 .map(|i| {
4085 let label = if i.label.is_empty() {
4086 "Quest board".to_string()
4087 } else {
4088 i.label.clone()
4089 };
4090 (label, distance(px, py, i.x, i.y))
4091 })
4092 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
4093 }
4094
4095 pub fn template_display_name(&self, template_id: &str) -> String {
4097 self.inventory_hints
4098 .get(template_id)
4099 .map(|h| h.display_name.clone())
4100 .filter(|n| !n.is_empty())
4101 .unwrap_or_else(|| humanize_template_id(template_id))
4102 }
4103
4104 pub fn placed_container_public_label(
4106 &self,
4107 c: &flatland_protocol::PlacedContainerView,
4108 ) -> String {
4109 let is_owner = match (self.character_id, c.owner_character_id) {
4110 (Some(me), Some(owner)) => me == owner,
4111 _ => false,
4112 };
4113 if is_owner {
4114 c.display_name.clone()
4115 } else {
4116 self.template_display_name(&c.template_id)
4117 }
4118 }
4119
4120 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
4122 let mut out = Vec::new();
4123 for stack in &self.inventory_stacks {
4124 if stack.template_id == KEY_TEMPLATE {
4125 out.push(KeychainEntry {
4126 stack: stack.clone(),
4127 stowed: false,
4128 });
4129 }
4130 }
4131 for stack in &self.keychain_stacks {
4132 if stack.template_id == KEY_TEMPLATE {
4133 out.push(KeychainEntry {
4134 stack: stack.clone(),
4135 stowed: true,
4136 });
4137 }
4138 }
4139 out
4140 }
4141
4142 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
4144 if stack.template_id != KEY_TEMPLATE {
4145 return None;
4146 }
4147 if let Some(name) = stack
4148 .props
4149 .get(PROP_OPENS_CONTAINER_NAME)
4150 .filter(|n| !n.is_empty())
4151 {
4152 return Some(name.clone());
4153 }
4154 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
4155 self.container_name_for_lock_id(opens)
4156 }
4157
4158 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
4160 if stack.template_id == KEY_TEMPLATE {
4161 self.template_display_name(KEY_TEMPLATE)
4162 } else {
4163 stack
4164 .display_name
4165 .clone()
4166 .unwrap_or_else(|| stack.template_id.clone())
4167 }
4168 }
4169
4170 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
4172 if stack.template_id != KEY_TEMPLATE {
4173 return String::new();
4174 }
4175 match self.key_pair_chest_label(stack) {
4176 Some(chest) if self.key_drop_blocked(stack) => {
4177 format!(" [key for {chest} — can't drop while locked]")
4178 }
4179 Some(chest) => format!(" [key for {chest}]"),
4180 None => " [key — unpaired]".into(),
4181 }
4182 }
4183
4184 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
4186 for c in &self.placed_containers {
4187 if c.lock_id.as_deref() == Some(lock) {
4188 return Some(c.display_name.clone());
4189 }
4190 }
4191 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
4192 self.worn
4193 .values()
4194 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
4195 })
4196 }
4197
4198 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
4200 if stack.template_id != KEY_TEMPLATE {
4201 return false;
4202 }
4203 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
4204 return false;
4205 };
4206 for c in &self.placed_containers {
4207 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
4208 return true;
4209 }
4210 }
4211 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
4212 return true;
4213 }
4214 self.worn
4215 .values()
4216 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
4217 }
4218
4219 fn container_name_in_stacks(
4220 stacks: &[flatland_protocol::ItemStack],
4221 lock: &str,
4222 ) -> Option<String> {
4223 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
4224 for s in stacks {
4225 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
4226 return Some(GameState::stack_container_label(s));
4227 }
4228 if let Some(name) = walk(&s.contents, lock) {
4229 return Some(name);
4230 }
4231 }
4232 None
4233 }
4234 walk(stacks, lock)
4235 }
4236
4237 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
4238 stack
4239 .props
4240 .get(PROP_CUSTOM_NAME)
4241 .cloned()
4242 .or_else(|| stack.display_name.clone())
4243 .unwrap_or_else(|| stack.template_id.clone())
4244 }
4245
4246 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
4247 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
4248 for s in stacks {
4249 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
4250 return true;
4251 }
4252 if walk(&s.contents, lock) {
4253 return true;
4254 }
4255 }
4256 false
4257 }
4258 walk(stacks, lock)
4259 }
4260
4261 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
4262 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
4263 return Some(stack.clone());
4264 }
4265 for worn in self.worn.values() {
4266 if worn.item_instance_id == Some(instance_id) {
4267 return Some(worn.clone());
4268 }
4269 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
4270 return Some(stack.clone());
4271 }
4272 }
4273 None
4274 }
4275
4276 pub fn location_context_lines(&self) -> Vec<ContextLine> {
4278 let (px, py) = self.player_position();
4279 let inside = self.effective_inside_building();
4280 let mut lines = Vec::new();
4281
4282 if let Some(kind) = self.terrain_at(px, py) {
4283 lines.push(ContextLine {
4284 on_top: true,
4285 text: format!("Terrain: {}", terrain_kind_label(kind)),
4286 });
4287 }
4288
4289 if let Some(id) = inside.as_ref() {
4290 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
4291 lines.push(ContextLine {
4292 on_top: true,
4293 text: format!("Inside: {}", b.label),
4294 });
4295 }
4296 }
4297
4298 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
4299
4300 for node in &self.resource_nodes {
4301 let dist = distance(px, py, node.x, node.y);
4302 if dist > NEARBY_SCAN_M {
4303 continue;
4304 }
4305 let on_top = dist <= ON_TOP_RADIUS_M;
4306 let state = match node.state {
4307 flatland_protocol::ResourceNodeState::Available => " — f harvest",
4308 flatland_protocol::ResourceNodeState::Harvesting => " (being harvested)",
4309 flatland_protocol::ResourceNodeState::Cooldown => " (depleted)",
4310 };
4311 let prefix = if on_top { "On" } else { "Near" };
4312 nearby.push((
4313 dist,
4314 ContextLine {
4315 on_top,
4316 text: format!("{prefix}: {} ({dist:.1}m){state}", node.label),
4317 },
4318 ));
4319 }
4320
4321 for drop in &self.ground_drops {
4322 let dist = distance(px, py, drop.x, drop.y);
4323 if dist > INTERACTION_RADIUS_M {
4324 continue;
4325 }
4326 let on_top = dist <= ON_TOP_RADIUS_M;
4327 let name = self.template_display_name(&drop.template_id);
4328 let prefix = if on_top { "On" } else { "Near" };
4329 let qty = if drop.quantity > 1 {
4330 format!(" ×{}", drop.quantity)
4331 } else {
4332 String::new()
4333 };
4334 nearby.push((
4335 dist,
4336 ContextLine {
4337 on_top,
4338 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
4339 },
4340 ));
4341 }
4342
4343 for c in &self.placed_containers {
4344 let dist = distance(px, py, c.x, c.y);
4345 if dist > CONTAINER_RANGE_M {
4346 continue;
4347 }
4348 let on_top = dist <= ON_TOP_RADIUS_M;
4349 let name = self.placed_container_public_label(c);
4350 let lock = if c.locked { " [locked]" } else { "" };
4351 let prefix = if on_top { "On" } else { "Near" };
4352 nearby.push((
4353 dist,
4354 ContextLine {
4355 on_top,
4356 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
4357 },
4358 ));
4359 }
4360
4361 for npc in &self.npcs {
4362 let dist = distance(px, py, npc.x, npc.y);
4363 if dist > NEARBY_SCAN_M {
4364 continue;
4365 }
4366 let on_top = dist <= ON_TOP_RADIUS_M;
4367 let prefix = if on_top { "On" } else { "Near" };
4368 nearby.push((
4369 dist,
4370 ContextLine {
4371 on_top,
4372 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
4373 },
4374 ));
4375 }
4376
4377 for door in &self.doors {
4378 let dist = distance(px, py, door.x, door.y);
4379 if dist > DOOR_INTERACTION_RADIUS_M {
4380 continue;
4381 }
4382 let building = self
4383 .buildings
4384 .iter()
4385 .find(|b| b.id == door.building_id)
4386 .map(|b| b.label.as_str())
4387 .unwrap_or(door.building_id.as_str());
4388 let action = if inside.is_some() && door.portal.is_some() {
4389 "exit"
4390 } else {
4391 "enter"
4392 };
4393 nearby.push((
4394 dist,
4395 ContextLine {
4396 on_top: dist <= ON_TOP_RADIUS_M,
4397 text: format!("{building} door ({dist:.1}m) — f {action}"),
4398 },
4399 ));
4400 }
4401
4402 if inside.is_none() {
4403 for inter in &self.interactables {
4404 if inter.kind != "quest_board" {
4405 continue;
4406 }
4407 let dist = distance(px, py, inter.x, inter.y);
4408 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
4409 continue;
4410 }
4411 let on_top = dist <= ON_TOP_RADIUS_M;
4412 let prefix = if on_top { "On" } else { "Near" };
4413 let label = if inter.label.is_empty() {
4414 "Quest board".to_string()
4415 } else {
4416 inter.label.clone()
4417 };
4418 nearby.push((
4419 dist,
4420 ContextLine {
4421 on_top,
4422 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
4423 },
4424 ));
4425 }
4426 }
4427
4428 if self.in_shallow_water() {
4429 let already = self
4430 .terrain_at(px, py)
4431 .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
4432 if !already {
4433 nearby.push((
4434 0.0,
4435 ContextLine {
4436 on_top: true,
4437 text: "Shallow water — f fill bottle".into(),
4438 },
4439 ));
4440 } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
4441 line.text.push_str(" — f fill bottle");
4442 }
4443 }
4444
4445 for entity in &self.entities {
4446 if entity.id == self.entity_id {
4447 continue;
4448 }
4449 let dist = distance(
4450 px,
4451 py,
4452 entity.transform.position.x,
4453 entity.transform.position.y,
4454 );
4455 if dist > NEARBY_SCAN_M {
4456 continue;
4457 }
4458 let label = if entity.label.is_empty() {
4459 format!("entity {}", entity.id)
4460 } else {
4461 entity.label.clone()
4462 };
4463 nearby.push((
4464 dist,
4465 ContextLine {
4466 on_top: dist <= ON_TOP_RADIUS_M,
4467 text: format!("Near: {label} ({dist:.1}m)"),
4468 },
4469 ));
4470 }
4471
4472 nearby.sort_by(|a, b| {
4473 a.0.partial_cmp(&b.0)
4474 .unwrap_or(std::cmp::Ordering::Equal)
4475 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
4476 });
4477 lines.extend(nearby.into_iter().map(|(_, l)| l));
4478
4479 if lines.is_empty() {
4480 lines.push(ContextLine {
4481 on_top: false,
4482 text: "(nothing notable nearby)".into(),
4483 });
4484 }
4485
4486 lines
4487 }
4488}
4489
4490#[derive(Debug, Clone)]
4492pub struct ContextLine {
4493 pub on_top: bool,
4494 pub text: String,
4495}
4496
4497const ON_TOP_RADIUS_M: f32 = 0.65;
4498const NEARBY_SCAN_M: f32 = 5.0;
4499
4500fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
4501 use flatland_protocol::TerrainKindView;
4502 match kind {
4503 TerrainKindView::Grass => "Grass",
4504 TerrainKindView::Dirt => "Dirt",
4505 TerrainKindView::Tilled => "Tilled",
4506 TerrainKindView::Desert => "Desert",
4507 TerrainKindView::Hill => "Hills",
4508 TerrainKindView::Bog => "Bog",
4509 TerrainKindView::ShallowWater => "Shallow water",
4510 TerrainKindView::DeepWater => "Deep water",
4511 TerrainKindView::Trail => "Trail",
4512 TerrainKindView::Road => "Road",
4513 TerrainKindView::Rock => "Rock",
4514 }
4515}
4516
4517fn humanize_template_id(template_id: &str) -> String {
4518 template_id
4519 .split('_')
4520 .map(|word| {
4521 let mut chars = word.chars();
4522 match chars.next() {
4523 None => String::new(),
4524 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
4525 }
4526 })
4527 .collect::<Vec<_>>()
4528 .join(" ")
4529}
4530
4531const HARVEST_RANGE_M: f32 = 1.5;
4533
4534pub struct GameClient<S: PlayConnection> {
4535 session: S,
4536 seq: Seq,
4537 pub state: GameState,
4538 last_move_forward: f32,
4539 last_move_strafe: f32,
4540}
4541
4542impl<S: PlayConnection> GameClient<S> {
4543 pub fn new(session: S) -> Self {
4544 let session_id = session.session_id();
4545 let entity_id = session.entity_id();
4546 let mut client = Self {
4547 session,
4548 seq: 0,
4549 last_move_forward: 0.0,
4550 last_move_strafe: 0.0,
4551 state: GameState {
4552 session_id,
4553 entity_id,
4554 character_id: None,
4555 tick: 0,
4556 chunk_rev: 0,
4557 content_rev: 0,
4558 publish_rev: 0,
4559 entities: Vec::new(),
4560 player: None,
4561 resource_nodes: Vec::new(),
4562 ground_drops: Vec::new(),
4563 placed_containers: Vec::new(),
4564 buildings: Vec::new(),
4565 doors: Vec::new(),
4566 interior_map: None,
4567 npcs: Vec::new(),
4568 blueprints: Vec::new(),
4569 world_width_m: 0.0,
4570 world_height_m: 0.0,
4571 terrain_zones: Vec::new(),
4572 z_platforms: Vec::new(),
4573 z_transitions: Vec::new(),
4574 world_clock: flatland_protocol::WorldClock::default(),
4575 inventory: std::collections::HashMap::new(),
4576 inventory_hints: std::collections::HashMap::new(),
4577 logs: VecDeque::new(),
4578 intents_sent: 0,
4579 ticks_received: 0,
4580 connected: false,
4581 disconnect_reason: None,
4582 show_stats: false,
4583 hud_log_hidden: false,
4584 show_equip_menu: false,
4585 equip_menu_index: 0,
4586 show_craft_menu: false,
4587 craft_menu_index: 0,
4588 craft_batch_quantity: 1,
4589 show_shop_menu: false,
4590 shop_catalog: None,
4591 bank_panel: None,
4592 bank_menu_index: 0,
4593 bank_ui_mode: BankUiMode::Menu,
4594 storage_panel: None,
4595 storage_menu_index: 0,
4596 storage_ui_mode: StorageUiMode::Menu,
4597 shop_tab: ShopTab::default(),
4598 shop_menu_index: 0,
4599 shop_quantity: 1,
4600 shop_trade_log: VecDeque::new(),
4601 show_npc_verb_menu: false,
4602 npc_verb_target: None,
4603 npc_verb_index: 0,
4604 player_verbs: crate::social::PlayerVerbState::default(),
4605 social_chat: crate::social::SocialChatState::default(),
4606 trade_ui: crate::social::TradeUiState::default(),
4607 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
4608 show_npc_chat: false,
4609 npc_chat: None,
4610 show_inventory_menu: false,
4611 inventory_menu_index: 0,
4612 inventory_tab: InventoryTab::OnPerson,
4613 inventory_filter: String::new(),
4614 inventory_filter_focused: false,
4615 show_move_picker: false,
4616 show_rename_prompt: false,
4617 show_worker_rename: false,
4618 rename_buffer: String::new(),
4619 move_picker_index: 0,
4620 move_picker: None,
4621 show_grant_picker: false,
4622 grant_picker_index: 0,
4623 grant_picker: None,
4624 show_destroy_picker: false,
4625 destroy_confirm_pending: false,
4626 destroy_picker: None,
4627 combat_target: None,
4628 combat_target_label: None,
4629 combat_fx: Vec::new(),
4630 in_combat: false,
4631 auto_attack: true,
4632 combat_has_los: false,
4633 attack_cd_ticks: 0,
4634 gcd_ticks: 0,
4635 weapon_ability_id: "unarmed".into(),
4636 mainhand_template_id: None,
4637 mainhand_label: None,
4638 offhand_template_id: None,
4639 offhand_label: None,
4640 mainhand_hand_slots: 1,
4641 defense: None,
4642 worn: BTreeMap::new(),
4643 carry_mass: 0.0,
4644 carry_mass_max: 0.0,
4645 encumbrance: flatland_protocol::EncumbranceState::Light,
4646 inventory_stacks: Vec::new(),
4647 keychain_stacks: Vec::new(),
4648 whisper_pouch_stacks: Vec::new(),
4649 combat_target_detail: None,
4650 statuses: Vec::new(),
4651 cast_progress: None,
4652 ability_cooldowns: Vec::new(),
4653 blocking_active: false,
4654 max_target_slots: 1,
4655 combat_slots: Vec::new(),
4656 rotation_presets: Vec::new(),
4657 known_abilities: Vec::new(),
4658 hotbar: vec![None; 9],
4659 max_abilities_per_rotation: 0,
4660 show_loadout_menu: false,
4661 show_keychain_menu: false,
4662 keychain_menu_index: 0,
4663 show_rotation_editor: false,
4664 loadout_menu_index: 0,
4665 loadout_hotbar_slot: 1,
4666 loadout_ability_index: 0,
4667 loadout_focus_presets: false,
4668 rotation_editor: RotationEditorState::default(),
4669 harvest_in_progress: false,
4670 harvest_started_at: None,
4671 pending_craft_ack: None,
4672 pending_worker_job_ack: None,
4673 quest_log: Vec::new(),
4674 interactables: Vec::new(),
4675 ledger: None,
4676 career: None,
4677 character_sheet_tab: CharacterSheetTab::Character,
4678 ledger_period: LedgerPeriod::Day,
4679 show_quest_offer: false,
4680 pending_quest_offer: None,
4681 show_quest_menu: false,
4682 quest_menu_index: 0,
4683 quest_withdraw_confirm: false,
4684 hired_workers: Vec::new(),
4685 show_workers_menu: false,
4686 workers_menu_index: 0,
4687 workers_menu_compact: false,
4688 worker_step_display: BTreeMap::new(),
4689 worker_error_display: BTreeMap::new(),
4690 show_worker_give_picker: false,
4691 worker_give_picker_index: 0,
4692 worker_give_picker: None,
4693 show_worker_give_target_picker: false,
4694 worker_give_target_picker_index: 0,
4695 worker_give_target_picker: None,
4696 show_worker_take_picker: false,
4697 worker_take_picker_index: 0,
4698 worker_take_picker: None,
4699 show_worker_teach_picker: false,
4700 worker_teach_picker_index: 0,
4701 worker_teach_picker: None,
4702 worker_route_editor: None,
4703 progression_curve: None,
4704 },
4705 };
4706 client.state.apply_client_ui_prefs();
4707 client
4708 }
4709
4710 pub fn entity_id(&self) -> EntityId {
4711 self.state.entity_id
4712 }
4713
4714 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
4715 if self.state.connected {
4716 return Ok(());
4717 }
4718
4719 loop {
4720 match self.session.next_event().await {
4721 Some(SessionEvent::Welcome {
4722 session_id,
4723 entity_id,
4724 snapshot,
4725 }) => {
4726 self.state
4727 .restore_from_welcome(session_id, entity_id, &snapshot);
4728 self.state.apply_client_ui_prefs();
4729 self.state.push_log(format!(
4730 "Connected — session {session_id}, entity {entity_id}"
4731 ));
4732 return Ok(());
4733 }
4734 Some(SessionEvent::Disconnected { .. }) => {
4735 anyhow::bail!("disconnected before welcome");
4736 }
4737 Some(_) => continue,
4738 None => anyhow::bail!("session closed before welcome"),
4739 }
4740 }
4741 }
4742
4743 pub fn drain_events(&mut self) {
4745 while let Some(event) = self.session.try_next_event() {
4746 if self.handle_event_sync(event).is_err() {
4747 break;
4748 }
4749 }
4750 }
4751
4752 pub async fn next_event(&mut self) -> Option<SessionEvent> {
4754 self.session.next_event().await
4755 }
4756
4757 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
4758 self.handle_event_sync(event)
4759 }
4760
4761 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
4762 match event {
4763 SessionEvent::Welcome {
4764 session_id,
4765 entity_id,
4766 snapshot,
4767 } => {
4768 let resumed = self.state.connected;
4769 self.state
4770 .restore_from_welcome(session_id, entity_id, &snapshot);
4771 if resumed {
4772 self.state.push_log(format!(
4773 "Session restored — session {session_id}, entity {entity_id}"
4774 ));
4775 }
4776 }
4777 SessionEvent::ContentUpdated { snapshot } => {
4778 self.state
4779 .apply_snapshot_fields(&snapshot, self.state.entity_id);
4780 self.state.push_log(format!(
4781 "World updated (content rev {})",
4782 snapshot.content_rev
4783 ));
4784 }
4785 SessionEvent::Tick(delta) => {
4786 self.state.apply_tick_fields(&delta, self.state.entity_id);
4787 self.state.ticks_received += 1;
4788 }
4789 SessionEvent::IntentAck {
4790 entity_id,
4791 seq,
4792 tick,
4793 } => {
4794 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
4795 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
4796 if *craft_seq == seq {
4797 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
4798 if batches > 1 {
4799 self.state.push_log(format!("Crafting {label} ×{batches}…"));
4800 } else {
4801 self.state.push_log(format!("Crafting {label}…"));
4802 }
4803 }
4804 }
4805 if self
4806 .state
4807 .pending_worker_job_ack
4808 .as_ref()
4809 .is_some_and(|p| p.seq == seq)
4810 {
4811 let pending = self.state.pending_worker_job_ack.take().unwrap();
4812 if pending.idle {
4813 self.state.push_log(format!(
4814 "Route cleared for {} — worker idle",
4815 pending.worker_label
4816 ));
4817 } else {
4818 self.state.push_log(format!(
4819 "Route saved for {} — {} stop(s), job loop active",
4820 pending.worker_label, pending.stop_count
4821 ));
4822 }
4823 if self
4824 .state
4825 .worker_route_editor
4826 .as_ref()
4827 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
4828 {
4829 self.close_worker_route_editor();
4830 }
4831 }
4832 }
4833 SessionEvent::Chat(msg) => {
4834 let label = match msg.channel {
4835 flatland_protocol::ChatChannel::Nearby => "nearby",
4836 flatland_protocol::ChatChannel::Direct => "speak",
4837 flatland_protocol::ChatChannel::Whisper => "whisper",
4838 flatland_protocol::ChatChannel::WhisperStone => "stone",
4839 };
4840 let clarity = match msg.clarity {
4841 flatland_protocol::ChatClarity::Clear => "",
4842 flatland_protocol::ChatClarity::Partial => "~",
4843 flatland_protocol::ChatClarity::Heavy => "…",
4844 };
4845 self.state.push_log(format!(
4846 "[{label}{clarity}] {}: {}",
4847 msg.from_name, msg.text
4848 ));
4849 let now_ms = std::time::SystemTime::now()
4850 .duration_since(std::time::UNIX_EPOCH)
4851 .map(|d| d.as_millis() as u64)
4852 .unwrap_or(0);
4853 self.state
4854 .social_chat
4855 .note_speech(&msg, self.state.entity_id, now_ms);
4856 self.state
4857 .social_chat
4858 .push(crate::social::ChatLogEntry::from_message(
4859 msg,
4860 self.state.entity_id,
4861 ));
4862 }
4863 SessionEvent::TradeOpened(panel) => {
4864 self.state.social_chat.pending_trade = None;
4865 let peer = panel.peer_name.clone();
4866 self.state.trade_ui.open(panel);
4867 self.state
4868 .social_chat
4869 .push_system(format!("Trade open with {peer} — p present · r ready · Esc cancel"));
4870 self.state
4871 .social_chat
4872 .push_cue(crate::social::AudioCue::TradeOpened);
4873 }
4874 SessionEvent::TradeClosed { reason } => {
4875 self.state.push_log(reason.clone());
4876 self.state.social_chat.push_system(reason);
4877 self.state.trade_ui.close();
4878 }
4879 SessionEvent::HarvestResult(result) => {
4880 self.state.clear_harvest_state();
4881 crate::harvest_trace!(
4882 entity_id = self.state.entity_id,
4883 node_id = %result.node_id,
4884 template = %result.item_template,
4885 quantity = result.quantity,
4886 client_tick = self.state.tick,
4887 "client applied harvest result"
4888 );
4889 self.state.push_log(format!(
4890 "Harvested {} x{} (on the ground — press P to pick up)",
4891 result.item_template, result.quantity
4892 ));
4893 }
4894 SessionEvent::CraftResult(result) => {
4895 for stack in &result.consumed {
4896 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
4897 *qty = qty.saturating_sub(stack.quantity);
4898 if *qty == 0 {
4899 self.state.inventory.remove(&stack.template_id);
4900 }
4901 }
4902 }
4903 for stack in &result.outputs {
4904 *self
4905 .state
4906 .inventory
4907 .entry(stack.template_id.clone())
4908 .or_insert(0) += stack.quantity;
4909 }
4910 if let Some(output) = result.outputs.first() {
4911 if result.batch_total > 1 {
4912 self.state.push_log(format!(
4913 "Crafted {} x{} ({}/{})",
4914 output.template_id,
4915 output.quantity,
4916 result.batch_index,
4917 result.batch_total
4918 ));
4919 } else {
4920 self.state.push_log(format!(
4921 "Crafted {} x{}",
4922 output.template_id, output.quantity
4923 ));
4924 }
4925 } else {
4926 self.state
4927 .push_log(format!("Craft finished: {}", result.blueprint_id));
4928 }
4929 }
4930 SessionEvent::Death(notice) => {
4931 self.state.clear_harvest_state();
4932 self.state.push_log(notice.message.clone());
4933 self.state.push_log(format!(
4934 "Respawned at ({:.1}, {:.1})",
4935 notice.respawn_x, notice.respawn_y
4936 ));
4937 }
4938 SessionEvent::Interaction(notice) => {
4939 if notice.message.starts_with("Harvest failed:") {
4940 self.state.clear_harvest_state();
4941 }
4942 if notice.message.starts_with("Can't do that:") {
4943 self.state.pending_craft_ack = None;
4944 if let Some(pending) = self.state.pending_worker_job_ack.take() {
4945 if let Some(w) = self
4946 .state
4947 .hired_workers
4948 .iter_mut()
4949 .find(|w| w.instance_id == pending.worker_instance_id)
4950 {
4951 w.route = pending.prev_route;
4952 w.mode = pending.prev_mode;
4953 w.step_label = pending.prev_step_label;
4954 w.last_error = pending.prev_last_error;
4955 }
4956 let reason = notice
4957 .message
4958 .strip_prefix("Can't do that:")
4959 .unwrap_or(¬ice.message)
4960 .trim();
4961 self.state.push_log(format!(
4962 "Route save failed for {}: {reason}",
4963 pending.worker_label
4964 ));
4965 }
4966 }
4967 if notice.message.starts_with("Cast failed:") {
4968 self.state.cast_progress = None;
4969 }
4970 if notice.message.contains("slain the") {
4971 self.state.combat_target = None;
4972 self.state.combat_target_label = None;
4973 }
4974 if notice.message.contains("wants to trade") {
4976 if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
4977 let from_name = notice
4978 .message
4979 .split(" wants to trade")
4980 .next()
4981 .unwrap_or("Player")
4982 .to_string();
4983 self.state.social_chat.pending_trade =
4984 Some(crate::social::PendingTradeRequest {
4985 from_entity,
4986 from_name: from_name.clone(),
4987 });
4988 self.state.social_chat.push_system(format!(
4989 "{from_name} wants to trade — [Y] accept · [N] decline"
4990 ));
4991 self.state
4992 .social_chat
4993 .push_cue(crate::social::AudioCue::TradeOffer);
4994 }
4995 }
4996 if notice.message.starts_with("trade request declined") {
4997 self.state
4998 .social_chat
4999 .push_system(notice.message.clone());
5000 self.state
5001 .social_chat
5002 .push_cue(crate::social::AudioCue::TradeDeclined);
5003 }
5004 self.state.apply_interaction_notice(¬ice);
5005 self.state.push_log(notice.message.clone());
5006 }
5007 SessionEvent::ShopOpened(catalog) => {
5008 self.state.apply_shop_catalog(catalog);
5009 }
5010 SessionEvent::BankOpened(panel) => {
5011 self.state.apply_bank_panel(panel);
5012 }
5013 SessionEvent::StorageOpened(panel) => {
5014 self.state.apply_storage_panel(panel);
5015 }
5016 SessionEvent::NpcTalkOpened(opened) => {
5017 self.state.show_npc_verb_menu = false;
5018 if self.state.npc_verb_target.is_none() {
5019 self.state.npc_verb_target = Some(opened.npc_id.clone());
5020 }
5021 let label = opened.npc_label.clone();
5022 let banner = if !opened.trade_allowed {
5023 Some("Trade is unavailable right now.".to_string())
5024 } else {
5025 None
5026 };
5027 self.state.show_npc_chat = true;
5028 self.state.npc_chat = Some(NpcChatState {
5029 npc_id: opened.npc_id,
5030 npc_label: opened.npc_label,
5031 lines: if opened.greeting.is_empty() {
5032 vec![]
5033 } else {
5034 vec![format!("{label}: {}", opened.greeting)]
5035 },
5036 input: String::new(),
5037 pending: opened.greeting.is_empty(),
5038 talk_depth: opened.talk_depth,
5039 trade_allowed: opened.trade_allowed,
5040 banner,
5041 });
5042 }
5043 SessionEvent::NpcTalkPending(_) => {
5044 if let Some(chat) = self.state.npc_chat.as_mut() {
5045 chat.pending = true;
5046 }
5047 }
5048 SessionEvent::NpcTalkReply(reply) => {
5049 if let Some(chat) = self.state.npc_chat.as_mut() {
5050 if chat.npc_id == reply.npc_id {
5051 chat.pending = false;
5052 if reply.trade_disabled {
5053 chat.trade_allowed = false;
5054 chat.banner = Some("Trade is unavailable right now.".to_string());
5055 }
5056 if reply.wind_down {
5057 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
5058 if chat.banner.is_none() {
5059 chat.banner =
5060 Some("They're wrapping up — keep it brief.".to_string());
5061 }
5062 }
5063 chat.lines
5064 .push(format!("{}: {}", chat.npc_label, reply.line));
5065 }
5066 }
5067 }
5068 SessionEvent::NpcTalkClosed(closed) => {
5069 if self
5070 .state
5071 .npc_chat
5072 .as_ref()
5073 .is_some_and(|c| c.npc_id == closed.npc_id)
5074 {
5075 self.state.show_npc_chat = false;
5076 self.state.npc_chat = None;
5077 }
5078 }
5079 SessionEvent::NpcTalkError(err) => {
5080 self.state.push_log(format!("Talk failed: {}", err.reason));
5081 if let Some(chat) = self.state.npc_chat.as_mut() {
5082 chat.pending = false;
5083 }
5084 }
5085 SessionEvent::UseResult(result) => {
5086 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
5089 *qty = qty.saturating_sub(1);
5090 if *qty == 0 {
5091 self.state.inventory.remove(&result.template_id);
5092 }
5093 }
5094 }
5095 SessionEvent::QuestOffer(offer) => {
5096 self.state.pending_quest_offer = Some(offer.clone());
5097 self.state.show_quest_offer = true;
5098 self.state
5099 .push_log(format!("Quest offered: {}", offer.title));
5100 }
5101 SessionEvent::QuestAccepted(notice) => {
5102 self.state.show_quest_offer = false;
5103 self.state.pending_quest_offer = None;
5104 self.state.push_log(notice.message);
5105 }
5106 SessionEvent::QuestWithdrawn(notice) => {
5107 self.state.show_quest_menu = false;
5108 self.state.quest_withdraw_confirm = false;
5109 self.state.push_log(notice.message);
5110 }
5111 SessionEvent::QuestStepCompleted(notice) => {
5112 self.state.push_log(notice.message);
5113 }
5114 SessionEvent::QuestCompleted(notice) => {
5115 self.state.push_log(notice.message);
5116 }
5117 SessionEvent::Disconnected { reason } => {
5118 self.state.clear_harvest_state();
5119 self.state.connected = false;
5120 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
5121 if let Some(r) = &self.state.disconnect_reason {
5122 self.state.push_log(format!("Disconnected: {r}"));
5123 } else {
5124 self.state.push_log("Disconnected from server");
5125 }
5126 }
5127 }
5128 Ok(())
5129 }
5130
5131 pub fn is_connected(&self) -> bool {
5132 self.state.connected
5133 }
5134
5135 pub fn close_overlays(&mut self) {
5136 self.state.show_stats = false;
5137 self.state.show_craft_menu = false;
5138 self.state.show_shop_menu = false;
5139 self.state.shop_catalog = None;
5140 self.state.show_npc_verb_menu = false;
5141 self.state.npc_verb_target = None;
5142 self.state.show_npc_chat = false;
5143 self.state.npc_chat = None;
5144 self.state.show_inventory_menu = false;
5145 self.state.show_loadout_menu = false;
5146 self.state.show_rotation_editor = false;
5147 self.state.rotation_editor.reset();
5148 self.state.show_rename_prompt = false;
5149 self.state.show_worker_rename = false;
5150 self.state.rename_buffer.clear();
5151 self.state.show_move_picker = false;
5152 self.state.move_picker = None;
5153 self.state.show_destroy_picker = false;
5154 self.state.destroy_confirm_pending = false;
5155 self.state.destroy_picker = None;
5156 self.state.show_quest_offer = false;
5157 self.state.pending_quest_offer = None;
5158 self.state.show_quest_menu = false;
5159 self.state.quest_withdraw_confirm = false;
5160 self.state.show_workers_menu = false;
5161 self.close_worker_give_picker();
5162 self.close_worker_give_target_picker();
5163 self.close_worker_take_picker();
5164 self.close_worker_teach_picker();
5165 self.state.worker_route_editor = None;
5166 }
5167
5168 pub fn back_on_esc(&mut self) -> bool {
5170 if self.state.social_chat.composer_open() {
5171 self.state.social_chat.close_composer();
5172 return true;
5173 }
5174 if self.state.player_verbs.open {
5175 self.state.player_verbs.close();
5176 return true;
5177 }
5178 if self.state.whisper_pouch_ui.open {
5179 self.state.whisper_pouch_ui.open = false;
5180 return true;
5181 }
5182 if self.state.trade_ui.panel.is_some() {
5183 self.state.trade_ui.close();
5185 return true;
5186 }
5187 if self.state.show_rename_prompt {
5188 self.cancel_rename_prompt();
5189 return true;
5190 }
5191 if self.state.show_worker_rename {
5192 self.cancel_worker_rename();
5193 return true;
5194 }
5195 if self.state.show_destroy_picker {
5196 if self.state.destroy_confirm_pending {
5197 self.cancel_destroy_confirm();
5198 } else {
5199 self.close_destroy_picker();
5200 }
5201 return true;
5202 }
5203 if self.state.show_move_picker {
5204 self.close_move_picker();
5205 return true;
5206 }
5207 if self.state.show_rotation_editor {
5208 match self.state.rotation_editor.mode {
5209 RotationEditorMode::List => {
5210 self.state.show_rotation_editor = false;
5211 self.state.rotation_editor.reset();
5212 }
5213 RotationEditorMode::EditLabel => {
5214 self.state.rotation_editor.label_buffer.clear();
5215 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
5216 }
5217 RotationEditorMode::PickAbility => {
5218 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
5219 }
5220 RotationEditorMode::EditSequence => {
5221 self.state.rotation_editor.draft = None;
5222 self.state.rotation_editor.mode = RotationEditorMode::List;
5223 }
5224 }
5225 return true;
5226 }
5227 if self.state.show_inventory_menu {
5228 self.close_inventory_menu();
5229 return true;
5230 }
5231 if self.state.show_craft_menu {
5232 self.close_craft_menu();
5233 return true;
5234 }
5235 if self.state.show_quest_offer {
5236 self.quest_offer_decline();
5237 return true;
5238 }
5239 if self.state.show_shop_menu {
5240 return false;
5242 }
5243 if self.state.bank_panel.is_some() {
5244 return false;
5245 }
5246 if self.state.storage_panel.is_some() {
5247 return false;
5248 }
5249 if self.state.show_npc_chat {
5250 return false;
5252 }
5253 if self.state.show_npc_verb_menu {
5254 self.state.show_npc_verb_menu = false;
5255 self.state.npc_verb_target = None;
5256 return true;
5257 }
5258 if self.state.show_quest_menu {
5259 if self.state.quest_withdraw_confirm {
5260 self.state.quest_withdraw_confirm = false;
5261 } else {
5262 self.state.show_quest_menu = false;
5263 }
5264 return true;
5265 }
5266 if self.state.worker_route_editor.is_some() {
5267 if self.re_at_root_sheet() {
5269 self.close_worker_route_editor();
5270 } else {
5271 self.re_sheet_back();
5272 }
5273 return true;
5274 }
5275 if self.state.show_worker_give_picker {
5276 self.close_worker_give_picker();
5277 return true;
5278 }
5279 if self.state.show_worker_give_target_picker {
5280 self.close_worker_give_target_picker();
5281 return true;
5282 }
5283 if self.state.show_worker_take_picker {
5284 self.close_worker_take_picker();
5285 return true;
5286 }
5287 if self.state.show_worker_teach_picker {
5288 self.close_worker_teach_picker();
5289 return true;
5290 }
5291 if self.state.show_workers_menu {
5292 self.state.show_workers_menu = false;
5293 return true;
5294 }
5295 if self.state.show_loadout_menu {
5296 self.state.show_loadout_menu = false;
5297 return true;
5298 }
5299 if self.state.show_stats {
5300 self.state.show_stats = false;
5301 return true;
5302 }
5303 if self.state.show_equip_menu {
5304 self.state.show_equip_menu = false;
5305 return true;
5306 }
5307 false
5308 }
5309
5310 pub fn toggle_stats(&mut self) {
5311 self.state.show_stats = !self.state.show_stats;
5312 if self.state.show_stats {
5313 self.state.character_sheet_tab = CharacterSheetTab::Character;
5314 self.state.show_craft_menu = false;
5315 self.state.show_shop_menu = false;
5316 self.state.shop_catalog = None;
5317 self.state.show_inventory_menu = false;
5318 self.state.show_equip_menu = false;
5319 }
5320 }
5321
5322 pub fn toggle_equip_menu(&mut self) {
5323 self.state.show_equip_menu = !self.state.show_equip_menu;
5324 if self.state.show_equip_menu {
5325 self.state.show_stats = false;
5326 self.state.show_craft_menu = false;
5327 self.state.show_shop_menu = false;
5328 self.state.shop_catalog = None;
5329 self.state.show_inventory_menu = false;
5330 self.state.show_loadout_menu = false;
5331 }
5332 }
5333
5334 pub fn cycle_character_sheet_tab(&mut self) {
5335 if self.state.show_stats {
5336 self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
5337 }
5338 }
5339
5340 pub fn set_ledger_period_digit(&mut self, c: char) {
5341 if self.state.show_stats {
5342 if let Some(p) = LedgerPeriod::from_digit(c) {
5343 self.state.ledger_period = p;
5344 self.state.character_sheet_tab = CharacterSheetTab::Ledger;
5345 }
5346 }
5347 }
5348
5349 pub fn cycle_ledger_period(&mut self) {
5350 if self.state.show_stats
5351 && self.state.character_sheet_tab == CharacterSheetTab::Ledger
5352 {
5353 self.state.ledger_period = self.state.ledger_period.cycle();
5354 }
5355 }
5356
5357 pub fn open_inventory_menu(&mut self) {
5358 self.state.show_inventory_menu = true;
5359 self.state.show_craft_menu = false;
5360 self.state.show_shop_menu = false;
5361 self.state.shop_catalog = None;
5362 self.state.show_stats = false;
5363 self.state.show_move_picker = false;
5364 self.state.move_picker = None;
5365 self.state.show_destroy_picker = false;
5366 self.state.destroy_confirm_pending = false;
5367 self.state.destroy_picker = None;
5368 self.state.show_rename_prompt = false;
5369 self.state.rename_buffer.clear();
5370 self.state.inventory_filter_focused = false;
5371 self.state.clamp_inventory_indices();
5372 }
5373
5374 pub fn close_inventory_menu(&mut self) {
5375 self.state.show_inventory_menu = false;
5376 self.state.show_move_picker = false;
5377 self.state.move_picker = None;
5378 self.close_grant_picker();
5379 self.state.show_destroy_picker = false;
5380 self.state.destroy_confirm_pending = false;
5381 self.state.destroy_picker = None;
5382 self.state.show_rename_prompt = false;
5383 self.state.rename_buffer.clear();
5384 self.state.inventory_filter_focused = false;
5385 }
5386
5387 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
5388 let Some(row) = self.state.inventory_selected_row() else {
5389 anyhow::bail!("inventory empty");
5390 };
5391 if !self.state.row_is_renameable_container(&row) {
5392 anyhow::bail!("only storage containers can be renamed");
5393 }
5394 let current = row
5395 .stack
5396 .display_name
5397 .clone()
5398 .unwrap_or_else(|| row.stack.template_id.clone());
5399 self.state.rename_buffer = current;
5400 self.state.show_rename_prompt = true;
5401 self.state.show_worker_rename = false;
5402 self.state.show_move_picker = false;
5403 self.state.show_destroy_picker = false;
5404 self.state.destroy_confirm_pending = false;
5405 Ok(())
5406 }
5407
5408 pub fn cancel_rename_prompt(&mut self) {
5409 self.state.show_rename_prompt = false;
5410 self.state.rename_buffer.clear();
5411 }
5412
5413 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
5414 let name = self.state.rename_buffer.trim().to_string();
5415 if name.is_empty() {
5416 anyhow::bail!("name cannot be empty");
5417 }
5418 let Some(row) = self.state.inventory_selected_row() else {
5419 anyhow::bail!("inventory empty");
5420 };
5421 let Some(instance_id) = row.stack.item_instance_id else {
5422 anyhow::bail!("item has no instance id");
5423 };
5424 self.seq += 1;
5425 self.session
5426 .submit_intent(Intent::RenameContainer {
5427 entity_id: self.state.entity_id,
5428 item_instance_id: instance_id,
5429 location: row.from.clone(),
5430 name,
5431 seq: self.seq,
5432 })
5433 .await?;
5434 self.state.intents_sent += 1;
5435 self.state.show_rename_prompt = false;
5436 self.state.rename_buffer.clear();
5437 Ok(())
5438 }
5439
5440 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
5441 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
5442 anyhow::bail!("no worker selected");
5443 };
5444 self.state.rename_buffer = worker.label.clone();
5445 self.state.show_worker_rename = true;
5446 self.state.show_rename_prompt = false;
5447 Ok(())
5448 }
5449
5450 pub fn cancel_worker_rename(&mut self) {
5451 self.state.show_worker_rename = false;
5452 self.state.rename_buffer.clear();
5453 }
5454
5455 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
5456 let name = self.state.rename_buffer.trim().to_string();
5457 if name.is_empty() {
5458 anyhow::bail!("name cannot be empty");
5459 }
5460 if name.chars().count() > 32 {
5461 anyhow::bail!("name must be 1–32 characters");
5462 }
5463 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
5464 anyhow::bail!("no worker selected");
5465 };
5466 let worker_instance_id = worker.instance_id.clone();
5467 self.seq += 1;
5468 self.session
5469 .submit_intent(Intent::RenameHiredWorker {
5470 entity_id: self.state.entity_id,
5471 worker_instance_id: worker_instance_id.clone(),
5472 name: name.clone(),
5473 seq: self.seq,
5474 })
5475 .await?;
5476 self.state.intents_sent += 1;
5477 if let Some(w) = self
5478 .state
5479 .hired_workers
5480 .iter_mut()
5481 .find(|w| w.instance_id == worker_instance_id)
5482 {
5483 w.label = name.clone();
5484 }
5485 if let Some(ed) = self.state.worker_route_editor.as_mut() {
5486 if ed.worker_instance_id == worker_instance_id {
5487 ed.worker_label = name.clone();
5488 }
5489 }
5490 self.state.show_worker_rename = false;
5491 self.state.rename_buffer.clear();
5492 self.state.push_log(format!("Renamed worker to \"{name}\""));
5493 Ok(())
5494 }
5495
5496 pub fn toggle_inventory_menu(&mut self) {
5497 if self.state.show_inventory_menu {
5498 self.close_inventory_menu();
5499 } else {
5500 self.open_inventory_menu();
5501 }
5502 }
5503
5504 pub fn inventory_menu_move(&mut self, delta: i32) {
5506 if self.state.show_grant_picker {
5507 let Some(picker) = self.state.grant_picker.as_ref() else {
5508 return;
5509 };
5510 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5511 let filter = picker.filter.clone();
5512 let n = labels.len();
5513 if n == 0 {
5514 return;
5515 }
5516 self.state.grant_picker_index = step_filtered_index(
5517 self.state.grant_picker_index,
5518 delta,
5519 n,
5520 |i| list_label_matches(&labels[i], &filter),
5521 );
5522 return;
5523 }
5524 if self.state.show_move_picker {
5525 let Some(picker) = self.state.move_picker.as_ref() else {
5526 return;
5527 };
5528 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5529 let filter = picker.filter.clone();
5530 let n = labels.len();
5531 if n == 0 {
5532 return;
5533 }
5534 self.state.move_picker_index = step_filtered_index(
5535 self.state.move_picker_index,
5536 delta,
5537 n,
5538 |i| list_label_matches(&labels[i], &filter),
5539 );
5540 self.state.clamp_move_picker_quantity();
5541 return;
5542 }
5543 let n = self.state.inventory_selectable_rows().len();
5544 if n == 0 {
5545 return;
5546 }
5547 let idx = self.state.inventory_menu_index as i32;
5548 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
5549 }
5550
5551 pub fn inventory_menu_page(&mut self, pages: i32) {
5553 if self.state.show_grant_picker {
5554 let Some(picker) = self.state.grant_picker.as_ref() else {
5555 return;
5556 };
5557 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5558 let filter = picker.filter.clone();
5559 let n = labels.len();
5560 self.state.grant_picker_index = page_filtered_index(
5561 self.state.grant_picker_index,
5562 pages,
5563 n,
5564 |i| list_label_matches(&labels[i], &filter),
5565 );
5566 return;
5567 }
5568 if self.state.show_move_picker {
5569 let Some(picker) = self.state.move_picker.as_ref() else {
5570 return;
5571 };
5572 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5573 let filter = picker.filter.clone();
5574 let n = labels.len();
5575 self.state.move_picker_index = page_filtered_index(
5576 self.state.move_picker_index,
5577 pages,
5578 n,
5579 |i| list_label_matches(&labels[i], &filter),
5580 );
5581 self.state.clamp_move_picker_quantity();
5582 return;
5583 }
5584 let n = self.state.inventory_selectable_rows().len();
5585 self.state.inventory_menu_index =
5586 page_list_index(self.state.inventory_menu_index, pages, n);
5587 }
5588
5589 pub fn cycle_inventory_tab(&mut self, forward: bool) {
5590 if self.state.show_move_picker
5591 || self.state.show_grant_picker
5592 || self.state.show_destroy_picker
5593 || self.state.show_rename_prompt
5594 || self.state.inventory_filter_focused
5595 {
5596 return;
5597 }
5598 self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
5599 self.state.inventory_menu_index = 0;
5600 self.state.clamp_inventory_indices();
5601 }
5602
5603 pub fn focus_inventory_filter(&mut self) {
5604 if self.state.show_grant_picker {
5605 if let Some(p) = self.state.grant_picker.as_mut() {
5606 p.filter_focused = true;
5607 }
5608 return;
5609 }
5610 if self.state.show_move_picker {
5611 if let Some(p) = self.state.move_picker.as_mut() {
5612 p.filter_focused = true;
5613 }
5614 return;
5615 }
5616 self.state.inventory_filter_focused = true;
5617 }
5618
5619 pub fn set_inventory_filter(&mut self, filter: String) {
5620 self.state.inventory_filter = filter;
5621 self.state.inventory_menu_index = 0;
5622 self.state.clamp_inventory_indices();
5623 }
5624
5625 pub fn append_inventory_filter_char(&mut self, ch: char) {
5626 if ch.is_control() {
5627 return;
5628 }
5629 if self.state.show_grant_picker {
5630 if let Some(p) = self.state.grant_picker.as_mut() {
5631 if p.filter_focused {
5632 p.filter.push(ch);
5633 self.state.grant_picker_index = 0;
5634 }
5635 }
5636 return;
5637 }
5638 if self.state.show_move_picker {
5639 if let Some(p) = self.state.move_picker.as_mut() {
5640 if p.filter_focused {
5641 p.filter.push(ch);
5642 self.state.move_picker_index = 0;
5643 self.state.clamp_move_picker_quantity();
5644 }
5645 }
5646 return;
5647 }
5648 if !self.state.inventory_filter_focused {
5649 return;
5650 }
5651 self.state.inventory_filter.push(ch);
5652 self.state.inventory_menu_index = 0;
5653 self.state.clamp_inventory_indices();
5654 }
5655
5656 pub fn inventory_filter_backspace(&mut self) {
5657 if self.state.show_grant_picker {
5658 if let Some(p) = self.state.grant_picker.as_mut() {
5659 if p.filter_focused {
5660 p.filter.pop();
5661 self.state.grant_picker_index = 0;
5662 }
5663 }
5664 return;
5665 }
5666 if self.state.show_move_picker {
5667 if let Some(p) = self.state.move_picker.as_mut() {
5668 if p.filter_focused {
5669 p.filter.pop();
5670 self.state.move_picker_index = 0;
5671 self.state.clamp_move_picker_quantity();
5672 }
5673 }
5674 return;
5675 }
5676 if !self.state.inventory_filter_focused {
5677 return;
5678 }
5679 self.state.inventory_filter.pop();
5680 self.state.inventory_menu_index = 0;
5681 self.state.clamp_inventory_indices();
5682 }
5683
5684 pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
5686 if self.state.show_grant_picker {
5687 if let Some(p) = self.state.grant_picker.as_mut() {
5688 if p.filter_focused {
5689 if !p.filter.is_empty() {
5690 p.filter.clear();
5691 self.state.grant_picker_index = 0;
5692 } else {
5693 p.filter_focused = false;
5694 }
5695 return true;
5696 }
5697 if !p.filter.is_empty() {
5698 p.filter.clear();
5699 self.state.grant_picker_index = 0;
5700 return true;
5701 }
5702 }
5703 return false;
5704 }
5705 if self.state.show_move_picker {
5706 if let Some(p) = self.state.move_picker.as_mut() {
5707 if p.filter_focused {
5708 if !p.filter.is_empty() {
5709 p.filter.clear();
5710 self.state.move_picker_index = 0;
5711 self.state.clamp_move_picker_quantity();
5712 } else {
5713 p.filter_focused = false;
5714 }
5715 return true;
5716 }
5717 if !p.filter.is_empty() {
5718 p.filter.clear();
5719 self.state.move_picker_index = 0;
5720 self.state.clamp_move_picker_quantity();
5721 return true;
5722 }
5723 }
5724 return false;
5725 }
5726 if self.state.inventory_filter_focused {
5727 if !self.state.inventory_filter.is_empty() {
5728 self.state.inventory_filter.clear();
5729 self.state.inventory_menu_index = 0;
5730 self.state.clamp_inventory_indices();
5731 } else {
5732 self.state.inventory_filter_focused = false;
5733 }
5734 return true;
5735 }
5736 if !self.state.inventory_filter.is_empty() {
5737 self.state.inventory_filter.clear();
5738 self.state.inventory_menu_index = 0;
5739 self.state.clamp_inventory_indices();
5740 return true;
5741 }
5742 false
5743 }
5744
5745 pub fn craft_menu_page(&mut self, pages: i32) {
5746 let n = self.state.blueprints.len();
5747 self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
5748 self.state.clamp_craft_batch_quantity();
5749 }
5750
5751 pub fn shop_menu_page(&mut self, pages: i32) {
5752 let n = self.state.shop_list_len();
5753 self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
5754 self.state.clamp_shop_quantity();
5755 }
5756
5757 pub fn workers_menu_page(&mut self, pages: i32) {
5758 let n = self.state.hired_workers.len();
5759 self.state.workers_menu_index =
5760 page_list_index(self.state.workers_menu_index, pages, n);
5761 }
5762
5763 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
5768 if self.state.show_destroy_picker {
5769 if self.state.destroy_confirm_pending {
5770 return self.confirm_destroy_item().await;
5771 }
5772 return self.request_destroy_confirm();
5773 }
5774 if self.state.show_grant_picker {
5775 return self.confirm_grant_picker().await;
5776 }
5777 if self.state.show_move_picker {
5778 return self.confirm_move_picker().await;
5779 }
5780 let Some(row) = self.state.inventory_selected_row() else {
5781 anyhow::bail!("inventory empty");
5782 };
5783 if row.is_equip_shell {
5784 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
5785 anyhow::bail!("not a worn item");
5786 };
5787 return self.equip_worn(slot, None).await;
5788 }
5789 if row.is_chest_shell {
5790 return self.open_chest_pickup_picker();
5791 }
5792 let template_id = row.stack.template_id.clone();
5793 let instance_id = row.stack.item_instance_id;
5794 let category = self.state.inventory_item_category(&template_id);
5795 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
5796
5797 if category == Some("weapon") {
5798 return self.equip_mainhand(Some(template_id)).await;
5799 }
5800 if category == Some("lodging") && on_person {
5801 if let Some(inst) = instance_id {
5802 return self.place_container(inst).await;
5803 }
5804 }
5805 if (category == Some("container") || category == Some("armor")) && on_person {
5806 if let Some(inst) = instance_id {
5807 let world_placeable = row.stack.world_placeable == Some(true)
5808 || template_id.contains("chest");
5809 if world_placeable {
5810 return self.place_container(inst).await;
5811 }
5812 if let Some(slot) = guess_body_slot(&template_id) {
5816 return self.equip_worn(slot, Some(inst)).await;
5817 }
5818 }
5819 }
5820 self.open_move_picker()
5824 }
5825
5826 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
5828 let Some(row) = self.state.inventory_selected_row() else {
5829 anyhow::bail!("inventory empty");
5830 };
5831 if row.from != flatland_protocol::InventoryLocation::Root {
5832 anyhow::bail!("select a consumable on your person");
5833 }
5834 if GameState::stack_is_item_grant(&row.stack) {
5835 return self.open_grant_target_picker();
5836 }
5837 let category = self
5838 .state
5839 .inventory_item_category(&row.stack.template_id);
5840 if category != Some("consumable") {
5841 anyhow::bail!("selected item is not consumable");
5842 }
5843 self.use_item(&row.stack.template_id).await
5844 }
5845
5846 pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
5848 let Some(row) = self.state.inventory_selected_row() else {
5849 anyhow::bail!("inventory empty");
5850 };
5851 if row.from != flatland_protocol::InventoryLocation::Root {
5852 anyhow::bail!("select a grant item on your person");
5853 }
5854 if !GameState::stack_is_item_grant(&row.stack) {
5855 anyhow::bail!("selected item does not grant onto gear");
5856 }
5857 let Some(grant_instance_id) = row.stack.item_instance_id else {
5858 anyhow::bail!("grant has no instance id");
5859 };
5860 let effect_id = GameState::grant_effect_id(&row.stack)
5861 .unwrap_or("?")
5862 .to_string();
5863 let mode = GameState::grant_mode(&row.stack).to_string();
5864 let options = self.state.grant_target_options(&row.stack);
5865 if options.is_empty() {
5866 anyhow::bail!("no valid gear to apply {effect_id} to");
5867 }
5868 let grant_label = row
5869 .stack
5870 .display_name
5871 .clone()
5872 .unwrap_or_else(|| row.stack.template_id.clone());
5873 self.state.show_grant_picker = true;
5874 self.state.grant_picker_index = 0;
5875 self.state.grant_picker = Some(GrantTargetPicker {
5876 grant_instance_id,
5877 grant_label,
5878 effect_id,
5879 mode,
5880 options,
5881 filter: String::new(),
5882 filter_focused: false,
5883 });
5884 Ok(())
5885 }
5886
5887 pub fn close_grant_picker(&mut self) {
5888 self.state.show_grant_picker = false;
5889 self.state.grant_picker = None;
5890 self.state.grant_picker_index = 0;
5891 }
5892
5893 pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
5894 let Some(picker) = self.state.grant_picker.clone() else {
5895 self.close_grant_picker();
5896 return Ok(());
5897 };
5898 let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
5899 self.close_grant_picker();
5900 return Ok(());
5901 };
5902 self.close_grant_picker();
5903 self.use_grant(picker.grant_instance_id, opt.target_instance_id)
5904 .await?;
5905 self.state.push_log(format!(
5906 "Applying {} onto {}…",
5907 picker.effect_id, opt.label
5908 ));
5909 Ok(())
5910 }
5911
5912 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
5916 let Some(row) = self.state.inventory_selected_row() else {
5917 anyhow::bail!("inventory empty");
5918 };
5919 if row.is_equip_shell {
5920 anyhow::bail!("this is a worn bag — press Enter to unequip it");
5921 }
5922 if row.is_chest_shell {
5923 return self.open_chest_pickup_picker();
5924 }
5925 let Some(instance_id) = row.stack.item_instance_id else {
5926 anyhow::bail!("item has no instance id");
5927 };
5928 let mut options = self.state.move_destinations_for(
5929 &row.from,
5930 row.from_parent_instance_id,
5931 row.stack.item_instance_id,
5932 &row.stack.template_id,
5933 );
5934 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
5935 let category = self.state.inventory_item_category(&row.stack.template_id);
5936 if on_person && category == Some("consumable") {
5937 if GameState::stack_is_item_grant(&row.stack) {
5938 options.insert(
5939 0,
5940 MoveOption {
5941 label: "Apply onto gear…".into(),
5942 kind: MoveOptionKind::GrantApply,
5943 },
5944 );
5945 } else {
5946 options.insert(
5947 0,
5948 MoveOption {
5949 label: "Use (eat / drink)".into(),
5950 kind: MoveOptionKind::Use,
5951 },
5952 );
5953 }
5954 }
5955 let item_label = row
5956 .stack
5957 .display_name
5958 .clone()
5959 .unwrap_or_else(|| row.stack.template_id.clone());
5960 let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
5963 self.state.move_picker = Some(MovePicker {
5964 item_instance_id: instance_id,
5965 from: row.from,
5966 item_label,
5967 template_id: row.stack.template_id.clone(),
5968 stack_quantity: row.stack.quantity,
5969 quantity: initial_qty.max(1),
5970 options,
5971 filter: String::new(),
5972 filter_focused: false,
5973 });
5974 self.state.move_picker_index = 0;
5975 self.state.show_move_picker = true;
5976 self.state.show_destroy_picker = false;
5977 self.state.destroy_confirm_pending = false;
5978 self.state.destroy_picker = None;
5979 self.state.clamp_move_picker_quantity();
5980 Ok(())
5981 }
5982
5983 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
5985 let Some(row) = self.state.inventory_selected_row() else {
5986 anyhow::bail!("inventory empty");
5987 };
5988 if !row.is_chest_shell {
5989 anyhow::bail!("not a placed chest");
5990 }
5991 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
5992 anyhow::bail!("not a placed chest");
5993 };
5994 let Some(instance_id) = row.stack.item_instance_id else {
5995 anyhow::bail!("chest has no instance id");
5996 };
5997 let chest = self
5998 .state
5999 .placed_containers
6000 .iter()
6001 .find(|c| c.id == *container_id)
6002 .cloned()
6003 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
6004 let (px, py) = self.state.player_position();
6005 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
6006 anyhow::bail!("too far from {}", chest.display_name);
6007 }
6008 if chest.locked && !chest.accessible {
6009 anyhow::bail!(
6010 "need the matching key for {} before picking it up",
6011 chest.display_name
6012 );
6013 }
6014 let options = self.state.chest_pickup_destinations(container_id);
6015 let item_label = row
6016 .stack
6017 .display_name
6018 .clone()
6019 .unwrap_or_else(|| row.stack.template_id.clone());
6020 self.state.move_picker = Some(MovePicker {
6021 item_instance_id: instance_id,
6022 from: row.from.clone(),
6023 item_label,
6024 template_id: row.stack.template_id.clone(),
6025 stack_quantity: 1,
6026 quantity: 1,
6027 options,
6028 filter: String::new(),
6029 filter_focused: false,
6030 });
6031 self.state.move_picker_index = 0;
6032 self.state.show_move_picker = true;
6033 self.state.show_destroy_picker = false;
6034 self.state.destroy_confirm_pending = false;
6035 self.state.destroy_picker = None;
6036 Ok(())
6037 }
6038
6039 pub fn close_move_picker(&mut self) {
6040 self.state.show_move_picker = false;
6041 self.state.move_picker = None;
6042 }
6043
6044 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
6045 self.state.move_picker_adjust_quantity(delta);
6046 }
6047
6048 pub fn move_picker_set_quantity_max(&mut self) {
6049 self.state.move_picker_set_quantity_max();
6050 }
6051
6052 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
6053 self.state.destroy_picker_adjust_quantity(delta);
6054 }
6055
6056 pub fn destroy_picker_set_quantity_max(&mut self) {
6057 self.state.destroy_picker_set_quantity_max();
6058 }
6059
6060 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
6061 let Some(picker) = self.state.move_picker.clone() else {
6062 self.close_move_picker();
6063 return Ok(());
6064 };
6065 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
6066 self.close_move_picker();
6067 return Ok(());
6068 };
6069 match option.kind {
6070 MoveOptionKind::Cancel => {
6071 self.close_move_picker();
6072 }
6073 MoveOptionKind::Use => {
6074 self.close_move_picker();
6075 self.use_item(&picker.template_id).await?;
6076 }
6077 MoveOptionKind::GrantApply => {
6078 self.close_move_picker();
6079 self.open_grant_target_picker()?;
6080 }
6081 MoveOptionKind::Drop => {
6082 self.close_move_picker();
6083 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
6084 if self.state.key_drop_blocked(&stack) {
6085 anyhow::bail!("cannot drop the key while its chest is locked");
6086 }
6087 }
6088 self.drop_item(picker.item_instance_id, picker.from).await?;
6089 self.state
6090 .push_log(format!("Dropped {}", picker.item_label));
6091 }
6092 MoveOptionKind::PickupPlaced {
6093 container_id,
6094 nest_location,
6095 nest_parent_instance_id,
6096 } => {
6097 self.close_move_picker();
6098 self.pickup_container(container_id.clone()).await?;
6099 let nest_into_bag = nest_parent_instance_id.is_some()
6100 || !matches!(
6101 nest_location,
6102 flatland_protocol::InventoryLocation::Root
6103 );
6104 if nest_into_bag {
6105 self.move_item(
6106 picker.item_instance_id,
6107 flatland_protocol::InventoryLocation::Root,
6108 nest_location,
6109 nest_parent_instance_id,
6110 None,
6111 )
6112 .await?;
6113 self.state
6114 .push_log(format!("Picked up {} into bag", picker.item_label));
6115 } else {
6116 self.state
6117 .push_log(format!("Picked up {}", picker.item_label));
6118 }
6119 }
6120 MoveOptionKind::Move {
6121 location,
6122 parent_instance_id,
6123 } => {
6124 self.close_move_picker();
6125 let qty = if picker.quantity >= picker.stack_quantity {
6126 None
6127 } else {
6128 Some(picker.quantity)
6129 };
6130 self.move_item(
6131 picker.item_instance_id,
6132 picker.from,
6133 location,
6134 parent_instance_id,
6135 qty,
6136 )
6137 .await?;
6138 let moved = qty.unwrap_or(picker.stack_quantity);
6139 if moved >= picker.stack_quantity {
6140 self.state.push_log(format!("Moved {}", picker.item_label));
6141 } else {
6142 self.state.push_log(format!(
6143 "Moved {} ×{} of {}",
6144 picker.item_label, moved, picker.stack_quantity
6145 ));
6146 }
6147 }
6148 }
6149 Ok(())
6150 }
6151
6152 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
6154 let Some(row) = self.state.inventory_selected_row() else {
6155 anyhow::bail!("inventory empty");
6156 };
6157 if row.is_equip_shell {
6158 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
6159 }
6160 if row.is_chest_shell {
6161 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
6162 }
6163 let Some(inst) = row.stack.item_instance_id else {
6164 anyhow::bail!("item has no instance id");
6165 };
6166 if self.state.key_drop_blocked(&row.stack) {
6167 anyhow::bail!("cannot drop the key while its chest is locked");
6168 }
6169 let label = row
6170 .stack
6171 .display_name
6172 .clone()
6173 .unwrap_or_else(|| row.stack.template_id.clone());
6174 self.drop_item(inst, row.from).await?;
6175 self.state.push_log(format!("Dropped {label}"));
6176 Ok(())
6177 }
6178
6179 pub async fn drop_item(
6180 &mut self,
6181 item_instance_id: uuid::Uuid,
6182 from: flatland_protocol::InventoryLocation,
6183 ) -> anyhow::Result<()> {
6184 self.seq += 1;
6185 self.session
6186 .submit_intent(Intent::DropItem {
6187 entity_id: self.state.entity_id,
6188 item_instance_id,
6189 from,
6190 seq: self.seq,
6191 })
6192 .await?;
6193 self.state.intents_sent += 1;
6194 Ok(())
6195 }
6196
6197 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
6199 let Some(row) = self.state.inventory_selected_row() else {
6200 anyhow::bail!("inventory empty");
6201 };
6202 if row.is_equip_shell {
6203 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
6204 }
6205 if row.is_chest_shell {
6206 anyhow::bail!("can't destroy a placed chest from the inventory list");
6207 }
6208 let Some(instance_id) = row.stack.item_instance_id else {
6209 anyhow::bail!("item has no instance id");
6210 };
6211 if self.state.key_drop_blocked(&row.stack) {
6212 anyhow::bail!("cannot destroy the key while its chest is locked");
6213 }
6214 let item_label = row
6215 .stack
6216 .display_name
6217 .clone()
6218 .unwrap_or_else(|| row.stack.template_id.clone());
6219 self.state.destroy_picker = Some(DestroyPicker {
6220 item_instance_id: instance_id,
6221 from: row.from,
6222 item_label,
6223 stack_quantity: row.stack.quantity,
6224 quantity: row.stack.quantity,
6225 });
6226 self.state.destroy_confirm_pending = false;
6227 self.state.show_destroy_picker = true;
6228 self.state.show_move_picker = false;
6229 self.state.move_picker = None;
6230 Ok(())
6231 }
6232
6233 pub fn close_destroy_picker(&mut self) {
6234 self.state.show_destroy_picker = false;
6235 self.state.destroy_confirm_pending = false;
6236 self.state.destroy_picker = None;
6237 }
6238
6239 pub fn cancel_destroy_confirm(&mut self) {
6240 self.state.destroy_confirm_pending = false;
6241 }
6242
6243 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
6244 if self.state.destroy_picker.is_none() {
6245 self.close_destroy_picker();
6246 return Ok(());
6247 }
6248 self.state.destroy_confirm_pending = true;
6249 Ok(())
6250 }
6251
6252 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
6253 let Some(picker) = self.state.destroy_picker.clone() else {
6254 self.close_destroy_picker();
6255 return Ok(());
6256 };
6257 let qty = if picker.quantity >= picker.stack_quantity {
6258 None
6259 } else {
6260 Some(picker.quantity)
6261 };
6262 self.destroy_item(picker.item_instance_id, picker.from, qty)
6263 .await?;
6264 let destroyed = qty.unwrap_or(picker.stack_quantity);
6265 if destroyed >= picker.stack_quantity {
6266 self.state
6267 .push_log(format!("Destroyed {}", picker.item_label));
6268 } else {
6269 self.state.push_log(format!(
6270 "Destroyed {} ×{} of {}",
6271 picker.item_label, destroyed, picker.stack_quantity
6272 ));
6273 }
6274 self.close_destroy_picker();
6275 Ok(())
6276 }
6277
6278 pub async fn destroy_item(
6279 &mut self,
6280 item_instance_id: uuid::Uuid,
6281 from: flatland_protocol::InventoryLocation,
6282 quantity: Option<u32>,
6283 ) -> anyhow::Result<()> {
6284 self.seq += 1;
6285 self.session
6286 .submit_intent(Intent::DestroyItem {
6287 entity_id: self.state.entity_id,
6288 item_instance_id,
6289 from,
6290 quantity,
6291 seq: self.seq,
6292 })
6293 .await?;
6294 self.state.intents_sent += 1;
6295 Ok(())
6296 }
6297
6298 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
6300 if let Some(row) = self.state.inventory_selected_row() {
6301 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
6302 return self.toggle_placed_chest_lock(container_id).await;
6303 }
6304 }
6305 self.toggle_nearby_chest_lock().await
6306 }
6307
6308 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
6309 let chest = self
6310 .state
6311 .placed_containers
6312 .iter()
6313 .find(|c| c.id == container_id)
6314 .cloned()
6315 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
6316 let (px, py) = self.state.player_position();
6317 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
6318 anyhow::bail!("too far from {}", chest.display_name);
6319 }
6320 if !chest.accessible && chest.locked {
6321 anyhow::bail!(
6322 "need the matching key for {} (each crafted chest has its own key)",
6323 chest.display_name
6324 );
6325 }
6326 let lock = !chest.locked;
6327 self.set_container_locked(
6328 flatland_protocol::InventoryLocation::Placed {
6329 container_id: chest.id.clone(),
6330 },
6331 lock,
6332 )
6333 .await?;
6334 self.state.push_log(if lock {
6335 format!("Locked {}", chest.display_name)
6336 } else {
6337 format!("Unlocked {}", chest.display_name)
6338 });
6339 Ok(())
6340 }
6341
6342 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
6344 let chest = self
6345 .state
6346 .nearest_placed_container(CONTAINER_RANGE_M)
6347 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
6348 self.toggle_placed_chest_lock(&chest.id).await
6349 }
6350
6351 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
6352 self.equip_mainhand(None).await
6353 }
6354
6355 pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
6356 if !self.state.is_alive() {
6357 anyhow::bail!("you are dead");
6358 }
6359 self.seq += 1;
6360 self.session
6361 .submit_intent(Intent::EquipOffhand {
6362 entity_id: self.state.entity_id,
6363 template_id,
6364 instance_id: None,
6365 seq: self.seq,
6366 })
6367 .await?;
6368 self.state.intents_sent += 1;
6369 Ok(())
6370 }
6371
6372 pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
6373 self.equip_offhand(None).await
6374 }
6375
6376 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
6377 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
6378 for slot in slots {
6379 self.equip_worn(slot, None).await?;
6380 }
6381 Ok(())
6382 }
6383
6384 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
6385 let (px, py) = self.state.player_position();
6386 let nearest = self
6387 .state
6388 .placed_containers
6389 .iter()
6390 .min_by(|a, b| {
6391 let da = (a.x - px).hypot(a.y - py);
6392 let db = (b.x - px).hypot(b.y - py);
6393 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
6394 })
6395 .cloned();
6396 let Some(chest) = nearest else {
6397 anyhow::bail!("no chest nearby");
6398 };
6399 if (chest.x - px).hypot(chest.y - py) > 2.0 {
6400 anyhow::bail!("too far from chest");
6401 }
6402 self.pickup_container(chest.id).await
6403 }
6404
6405 pub async fn equip_worn(
6406 &mut self,
6407 slot: BodySlot,
6408 instance_id: Option<uuid::Uuid>,
6409 ) -> anyhow::Result<()> {
6410 self.seq += 1;
6411 self.session
6412 .submit_intent(Intent::EquipWorn {
6413 entity_id: self.state.entity_id,
6414 slot,
6415 instance_id,
6416 seq: self.seq,
6417 })
6418 .await?;
6419 self.state.intents_sent += 1;
6420 Ok(())
6421 }
6422
6423 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
6424 self.seq += 1;
6425 self.session
6426 .submit_intent(Intent::PlaceContainer {
6427 entity_id: self.state.entity_id,
6428 item_instance_id,
6429 seq: self.seq,
6430 })
6431 .await?;
6432 self.state.intents_sent += 1;
6433 Ok(())
6434 }
6435
6436 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
6437 self.seq += 1;
6438 self.session
6439 .submit_intent(Intent::PickupContainer {
6440 entity_id: self.state.entity_id,
6441 container_id,
6442 seq: self.seq,
6443 })
6444 .await?;
6445 self.state.intents_sent += 1;
6446 Ok(())
6447 }
6448
6449 pub async fn move_item(
6450 &mut self,
6451 item_instance_id: uuid::Uuid,
6452 from: flatland_protocol::InventoryLocation,
6453 to: flatland_protocol::InventoryLocation,
6454 to_parent_instance_id: Option<uuid::Uuid>,
6455 quantity: Option<u32>,
6456 ) -> anyhow::Result<()> {
6457 self.seq += 1;
6458 self.session
6459 .submit_intent(Intent::MoveItem {
6460 entity_id: self.state.entity_id,
6461 item_instance_id,
6462 from,
6463 to,
6464 to_parent_instance_id,
6465 quantity,
6466 seq: self.seq,
6467 })
6468 .await?;
6469 self.state.intents_sent += 1;
6470 Ok(())
6471 }
6472
6473 pub async fn set_container_locked(
6474 &mut self,
6475 location: flatland_protocol::InventoryLocation,
6476 locked: bool,
6477 ) -> anyhow::Result<()> {
6478 self.seq += 1;
6479 self.session
6480 .submit_intent(Intent::SetContainerLocked {
6481 entity_id: self.state.entity_id,
6482 location,
6483 locked,
6484 seq: self.seq,
6485 })
6486 .await?;
6487 self.state.intents_sent += 1;
6488 Ok(())
6489 }
6490
6491 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
6492 if !self.state.is_alive() {
6493 anyhow::bail!("you are dead");
6494 }
6495 self.seq += 1;
6496 self.session
6497 .submit_intent(Intent::Use {
6498 entity_id: self.state.entity_id,
6499 template_id: template_id.to_string(),
6500 seq: self.seq,
6501 })
6502 .await?;
6503 self.state.intents_sent += 1;
6504 Ok(())
6505 }
6506
6507 pub async fn use_grant(
6509 &mut self,
6510 grant_instance_id: uuid::Uuid,
6511 target_instance_id: uuid::Uuid,
6512 ) -> anyhow::Result<()> {
6513 if !self.state.is_alive() {
6514 anyhow::bail!("you are dead");
6515 }
6516 self.seq += 1;
6517 self.session
6518 .submit_intent(Intent::UseGrant {
6519 entity_id: self.state.entity_id,
6520 grant_instance_id,
6521 target_instance_id,
6522 seq: self.seq,
6523 })
6524 .await?;
6525 self.state.intents_sent += 1;
6526 Ok(())
6527 }
6528
6529 pub fn open_craft_menu(&mut self) {
6530 self.state.show_craft_menu = true;
6531 self.state.show_shop_menu = false;
6532 self.state.shop_catalog = None;
6533 self.state.show_stats = false;
6534 self.state.show_inventory_menu = false;
6535 if self.state.blueprints.is_empty() {
6536 self.state.craft_menu_index = 0;
6537 self.state.craft_batch_quantity = 1;
6538 return;
6539 }
6540 self.state.craft_menu_index = self
6541 .state
6542 .craft_menu_index
6543 .min(self.state.blueprints.len() - 1);
6544 if let Some(idx) = self
6545 .state
6546 .blueprints
6547 .iter()
6548 .position(|bp| self.state.can_craft_blueprint(bp))
6549 {
6550 self.state.craft_menu_index = idx;
6551 }
6552 self.state.clamp_craft_batch_quantity();
6553 }
6554
6555 pub fn close_craft_menu(&mut self) {
6556 self.state.show_craft_menu = false;
6557 }
6558
6559 pub fn toggle_keychain_menu(&mut self) {
6560 if self.state.show_keychain_menu {
6561 self.close_keychain_menu();
6562 } else {
6563 self.state.show_keychain_menu = true;
6564 self.state.show_craft_menu = false;
6565 self.state.show_shop_menu = false;
6566 self.state.show_inventory_menu = false;
6567 let n = self.state.keychain_entries().len();
6568 if n == 0 {
6569 self.state.keychain_menu_index = 0;
6570 } else {
6571 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
6572 }
6573 }
6574 }
6575
6576 pub fn close_keychain_menu(&mut self) {
6577 self.state.show_keychain_menu = false;
6578 }
6579
6580 pub fn keychain_menu_move(&mut self, delta: i32) {
6581 let n = self.state.keychain_entries().len();
6582 if n == 0 {
6583 self.state.keychain_menu_index = 0;
6584 return;
6585 }
6586 let idx = self.state.keychain_menu_index as i32 + delta;
6587 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
6588 }
6589
6590 pub fn keychain_menu_page(&mut self, pages: i32) {
6591 let n = self.state.keychain_entries().len();
6592 self.state.keychain_menu_index =
6593 page_list_index(self.state.keychain_menu_index, pages, n);
6594 }
6595
6596 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
6597 if !self.state.is_alive() {
6598 anyhow::bail!("you are dead");
6599 }
6600 let entries = self.state.keychain_entries();
6601 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
6602 anyhow::bail!("nothing selected");
6603 };
6604 let Some(instance_id) = entry.stack.item_instance_id else {
6605 anyhow::bail!("key has no instance id");
6606 };
6607 if entry.stowed {
6608 self.move_item(
6609 instance_id,
6610 flatland_protocol::InventoryLocation::Keychain,
6611 flatland_protocol::InventoryLocation::Root,
6612 None,
6613 Some(1),
6614 )
6615 .await
6616 } else {
6617 self.move_item(
6618 instance_id,
6619 flatland_protocol::InventoryLocation::Root,
6620 flatland_protocol::InventoryLocation::Keychain,
6621 None,
6622 Some(1),
6623 )
6624 .await
6625 }
6626 }
6627
6628 pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
6629 let npc_id = self
6630 .state
6631 .shop_catalog
6632 .as_ref()
6633 .map(|c| c.npc_id.clone());
6634 self.state.show_shop_menu = false;
6635 self.state.shop_catalog = None;
6636 self.state.clear_shop_trade_log();
6637 if let Some(npc_id) = npc_id {
6638 self.seq += 1;
6639 self.session
6640 .submit_intent(Intent::ShopClose {
6641 entity_id: self.state.entity_id,
6642 npc_id,
6643 seq: self.seq,
6644 })
6645 .await?;
6646 self.state.intents_sent += 1;
6647 }
6648 Ok(())
6649 }
6650
6651 pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
6652 let Some(panel) = self.state.bank_panel.clone() else {
6653 return Ok(());
6654 };
6655 self.seq += 1;
6656 self.session
6657 .submit_intent(Intent::BankDeposit {
6658 entity_id: self.state.entity_id,
6659 npc_id: panel.npc_id,
6660 amount_copper,
6661 seq: self.seq,
6662 })
6663 .await?;
6664 self.state.intents_sent += 1;
6665 Ok(())
6666 }
6667
6668 pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
6669 let Some(panel) = self.state.bank_panel.clone() else {
6670 return Ok(());
6671 };
6672 self.seq += 1;
6673 self.session
6674 .submit_intent(Intent::BankWithdraw {
6675 entity_id: self.state.entity_id,
6676 npc_id: panel.npc_id,
6677 amount_copper,
6678 seq: self.seq,
6679 })
6680 .await?;
6681 self.state.intents_sent += 1;
6682 Ok(())
6683 }
6684
6685 pub async fn bank_transfer(
6686 &mut self,
6687 to_character_id: Option<uuid::Uuid>,
6688 to_name: String,
6689 amount_copper: u64,
6690 ) -> anyhow::Result<()> {
6691 let Some(panel) = self.state.bank_panel.clone() else {
6692 return Ok(());
6693 };
6694 self.seq += 1;
6695 self.session
6696 .submit_intent(Intent::BankTransfer {
6697 entity_id: self.state.entity_id,
6698 npc_id: panel.npc_id,
6699 to_character_id,
6700 to_name,
6701 amount_copper,
6702 seq: self.seq,
6703 })
6704 .await?;
6705 self.state.intents_sent += 1;
6706 Ok(())
6707 }
6708
6709 pub fn bank_menu_move(&mut self, delta: i32) {
6710 let n = self.state.bank_menu_options().len();
6711 if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
6712 return;
6713 }
6714 let idx = self.state.bank_menu_index as i32;
6715 self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6716 }
6717
6718 pub fn storage_menu_move(&mut self, delta: i32) {
6719 let n = self.state.storage_menu_options().len();
6720 if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
6721 return;
6722 }
6723 let idx = self.state.storage_menu_index as i32;
6724 self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6725 }
6726
6727 pub fn storage_pick_move(&mut self, delta: i32) {
6728 let n = match &self.state.storage_ui_mode {
6729 StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
6730 StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
6731 self.state.storage_vault_options().len()
6732 }
6733 StorageUiMode::Menu
6734 | StorageUiMode::StoreAmount { .. }
6735 | StorageUiMode::TakeAmount { .. }
6736 | StorageUiMode::ShipAmount { .. } => 0,
6737 };
6738 if n == 0 {
6739 return;
6740 }
6741 match &mut self.state.storage_ui_mode {
6742 StorageUiMode::StorePick { index }
6743 | StorageUiMode::TakePick { index }
6744 | StorageUiMode::ShipPick { index, .. } => {
6745 *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
6746 }
6747 StorageUiMode::Menu
6748 | StorageUiMode::StoreAmount { .. }
6749 | StorageUiMode::TakeAmount { .. }
6750 | StorageUiMode::ShipAmount { .. } => {}
6751 }
6752 }
6753
6754 pub fn storage_ui_back(&mut self) {
6755 self.state.storage_ui_mode = match &self.state.storage_ui_mode {
6756 StorageUiMode::StoreAmount { pick_index, .. } => StorageUiMode::StorePick {
6757 index: *pick_index,
6758 },
6759 StorageUiMode::TakeAmount { pick_index, .. } => StorageUiMode::TakePick {
6760 index: *pick_index,
6761 },
6762 StorageUiMode::ShipAmount {
6763 dest_building_id,
6764 dest_label,
6765 pick_index,
6766 ..
6767 } => StorageUiMode::ShipPick {
6768 dest_building_id: dest_building_id.clone(),
6769 dest_label: dest_label.clone(),
6770 index: *pick_index,
6771 },
6772 StorageUiMode::StorePick { .. }
6773 | StorageUiMode::TakePick { .. }
6774 | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
6775 StorageUiMode::Menu => StorageUiMode::Menu,
6776 };
6777 }
6778
6779 pub fn storage_amount_append_char(&mut self, c: char) {
6780 match &mut self.state.storage_ui_mode {
6781 StorageUiMode::StoreAmount { input, .. }
6782 | StorageUiMode::TakeAmount { input, .. }
6783 | StorageUiMode::ShipAmount { input, .. } => {
6784 if c.is_ascii_digit() && input.len() < 8 {
6785 input.push(c);
6786 }
6787 }
6788 _ => {}
6789 }
6790 }
6791
6792 pub fn storage_amount_backspace(&mut self) {
6793 match &mut self.state.storage_ui_mode {
6794 StorageUiMode::StoreAmount { input, .. }
6795 | StorageUiMode::TakeAmount { input, .. }
6796 | StorageUiMode::ShipAmount { input, .. } => {
6797 input.pop();
6798 }
6799 _ => {}
6800 }
6801 }
6802
6803 pub fn storage_ui_typing(&self) -> bool {
6804 matches!(
6805 self.state.storage_ui_mode,
6806 StorageUiMode::StoreAmount { .. }
6807 | StorageUiMode::TakeAmount { .. }
6808 | StorageUiMode::ShipAmount { .. }
6809 )
6810 }
6811
6812 pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
6813 match self.state.storage_ui_mode.clone() {
6814 StorageUiMode::Menu => {
6815 let index = self.state.storage_menu_index;
6816 match index {
6817 0 => {
6818 let opts = self.state.storage_store_options();
6819 if opts.is_empty() {
6820 self.state.push_log("Nothing loose to store.");
6821 return Ok(());
6822 }
6823 self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
6824 }
6825 1 => {
6826 let opts = self.state.storage_vault_options();
6827 if opts.is_empty() {
6828 self.state.push_log("Vault is empty.");
6829 return Ok(());
6830 }
6831 self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
6832 }
6833 n => {
6834 let dest = self
6835 .state
6836 .storage_panel
6837 .as_ref()
6838 .and_then(|p| p.ship_destinations.get(n - 2))
6839 .cloned();
6840 let Some(dest) = dest else {
6841 return Ok(());
6842 };
6843 let opts = self.state.storage_vault_options();
6844 if opts.is_empty() {
6845 self.state
6846 .push_log("Vault is empty — nothing to ship.");
6847 return Ok(());
6848 }
6849 self.state.storage_ui_mode = StorageUiMode::ShipPick {
6850 dest_building_id: dest.building_id,
6851 dest_label: dest.label,
6852 index: 0,
6853 };
6854 }
6855 }
6856 }
6857 StorageUiMode::StorePick { index } => {
6858 let opts = self.state.storage_store_options();
6859 let Some(opt) = opts.get(index) else {
6860 self.state.push_log("Nothing loose to store.");
6861 self.state.storage_ui_mode = StorageUiMode::Menu;
6862 return Ok(());
6863 };
6864 self.state.storage_ui_mode = StorageUiMode::StoreAmount {
6865 pick_index: index,
6866 item_instance_id: opt.item_instance_id,
6867 label: opt.label.clone(),
6868 max_qty: opt.quantity.max(1),
6869 input: String::new(),
6870 };
6871 }
6872 StorageUiMode::TakePick { index } => {
6873 let opts = self.state.storage_vault_options();
6874 let Some(opt) = opts.get(index) else {
6875 self.state.push_log("Vault is empty.");
6876 self.state.storage_ui_mode = StorageUiMode::Menu;
6877 return Ok(());
6878 };
6879 self.state.storage_ui_mode = StorageUiMode::TakeAmount {
6880 pick_index: index,
6881 item_instance_id: opt.item_instance_id,
6882 label: opt.label.clone(),
6883 max_qty: opt.quantity.max(1),
6884 input: String::new(),
6885 };
6886 }
6887 StorageUiMode::ShipPick {
6888 dest_building_id,
6889 dest_label,
6890 index,
6891 } => {
6892 let opts = self.state.storage_vault_options();
6893 let Some(opt) = opts.get(index) else {
6894 self.state
6895 .push_log("Vault is empty — nothing to ship.");
6896 self.state.storage_ui_mode = StorageUiMode::Menu;
6897 return Ok(());
6898 };
6899 self.state.storage_ui_mode = StorageUiMode::ShipAmount {
6900 dest_building_id,
6901 dest_label,
6902 pick_index: index,
6903 item_instance_id: opt.item_instance_id,
6904 label: opt.label.clone(),
6905 max_qty: opt.quantity.max(1),
6906 input: String::new(),
6907 };
6908 }
6909 StorageUiMode::StoreAmount {
6910 item_instance_id,
6911 max_qty,
6912 input,
6913 ..
6914 } => {
6915 let Some(qty) = parse_storage_quantity(&input) else {
6916 self.state
6917 .push_log("Enter a quantity (blank or 0 = all).");
6918 return Ok(());
6919 };
6920 let qty = qty.map(|n| n.min(max_qty).max(1));
6921 self.storage_store(item_instance_id, qty).await?;
6922 self.state.storage_ui_mode = StorageUiMode::Menu;
6923 }
6924 StorageUiMode::TakeAmount {
6925 item_instance_id,
6926 max_qty,
6927 input,
6928 ..
6929 } => {
6930 let Some(qty) = parse_storage_quantity(&input) else {
6931 self.state
6932 .push_log("Enter a quantity (blank or 0 = all).");
6933 return Ok(());
6934 };
6935 let qty = qty.map(|n| n.min(max_qty).max(1));
6936 self.storage_take(item_instance_id, qty).await?;
6937 self.state.storage_ui_mode = StorageUiMode::Menu;
6938 }
6939 StorageUiMode::ShipAmount {
6940 dest_building_id,
6941 item_instance_id,
6942 max_qty,
6943 input,
6944 ..
6945 } => {
6946 let Some(qty) = parse_storage_quantity(&input) else {
6947 self.state
6948 .push_log("Enter a quantity (blank or 0 = all).");
6949 return Ok(());
6950 };
6951 let qty = qty.map(|n| n.min(max_qty).max(1));
6952 self.storage_ship(dest_building_id, item_instance_id, qty)
6953 .await?;
6954 self.state.storage_ui_mode = StorageUiMode::Menu;
6955 }
6956 }
6957 Ok(())
6958 }
6959
6960 pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
6961 match self.state.bank_ui_mode.clone() {
6962 BankUiMode::Menu => {
6963 let choice = self
6964 .state
6965 .bank_menu_options()
6966 .get(self.state.bank_menu_index)
6967 .copied()
6968 .unwrap_or("Deposit…");
6969 match choice {
6970 "Withdraw…" => {
6971 self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
6972 input: String::new(),
6973 };
6974 }
6975 "Deposit all" => self.bank_deposit(0).await?,
6976 "Withdraw all" => self.bank_withdraw(0).await?,
6977 "Transfer…" => {
6978 self.state.bank_ui_mode = BankUiMode::TransferName {
6979 input: String::new(),
6980 };
6981 }
6982 _ => {
6983 self.state.bank_ui_mode = BankUiMode::DepositAmount {
6984 input: String::new(),
6985 };
6986 }
6987 }
6988 }
6989 BankUiMode::DepositAmount { input } => {
6990 let Some(amount) = parse_bank_copper_amount(&input) else {
6991 self.state
6992 .push_log("Enter a copper amount (blank or 0 = everything on person).");
6993 return Ok(());
6994 };
6995 self.bank_deposit(amount).await?;
6996 self.state.bank_ui_mode = BankUiMode::Menu;
6997 }
6998 BankUiMode::WithdrawAmount { input } => {
6999 let Some(amount) = parse_bank_copper_amount(&input) else {
7000 self.state
7001 .push_log("Enter a copper amount (blank or 0 = full ledger).");
7002 return Ok(());
7003 };
7004 self.bank_withdraw(amount).await?;
7005 self.state.bank_ui_mode = BankUiMode::Menu;
7006 }
7007 BankUiMode::TransferName { input } => {
7008 let name = input.trim().to_string();
7009 if name.is_empty() {
7010 self.state.push_log("Enter the recipient character name.");
7011 return Ok(());
7012 }
7013 self.state.bank_ui_mode = BankUiMode::TransferAmount {
7014 to_name: name,
7015 input: String::new(),
7016 };
7017 }
7018 BankUiMode::TransferAmount { to_name, input } => {
7019 let amount: u64 = match input.trim().parse() {
7020 Ok(v) if v > 0 => v,
7021 _ => {
7022 self.state
7023 .push_log("Enter a positive copper amount to transfer.");
7024 return Ok(());
7025 }
7026 };
7027 self.bank_transfer(None, to_name, amount).await?;
7028 self.state.bank_ui_mode = BankUiMode::Menu;
7029 }
7030 }
7031 Ok(())
7032 }
7033
7034 pub fn bank_transfer_back(&mut self) {
7035 match &self.state.bank_ui_mode {
7036 BankUiMode::TransferAmount { to_name, .. } => {
7037 self.state.bank_ui_mode = BankUiMode::TransferName {
7038 input: to_name.clone(),
7039 };
7040 }
7041 BankUiMode::TransferName { .. }
7042 | BankUiMode::DepositAmount { .. }
7043 | BankUiMode::WithdrawAmount { .. } => {
7044 self.state.bank_ui_mode = BankUiMode::Menu;
7045 }
7046 BankUiMode::Menu => {}
7047 }
7048 }
7049
7050 pub fn bank_transfer_append_char(&mut self, c: char) {
7051 match &mut self.state.bank_ui_mode {
7052 BankUiMode::TransferName { input } => {
7053 if input.len() < 32 && !c.is_control() {
7054 input.push(c);
7055 }
7056 }
7057 BankUiMode::DepositAmount { input }
7058 | BankUiMode::WithdrawAmount { input }
7059 | BankUiMode::TransferAmount { input, .. } => {
7060 if c.is_ascii_digit() && input.len() < 12 {
7061 input.push(c);
7062 }
7063 }
7064 BankUiMode::Menu => {}
7065 }
7066 }
7067
7068 pub fn bank_transfer_backspace(&mut self) {
7069 match &mut self.state.bank_ui_mode {
7070 BankUiMode::TransferName { input }
7071 | BankUiMode::DepositAmount { input }
7072 | BankUiMode::WithdrawAmount { input }
7073 | BankUiMode::TransferAmount { input, .. } => {
7074 input.pop();
7075 }
7076 BankUiMode::Menu => {}
7077 }
7078 }
7079
7080 pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
7081 let npc_id = self
7082 .state
7083 .bank_panel
7084 .as_ref()
7085 .map(|p| p.npc_id.clone());
7086 self.state.clear_bank_panel();
7087 if let Some(npc_id) = npc_id {
7088 self.seq += 1;
7089 self.session
7090 .submit_intent(Intent::BankClose {
7091 entity_id: self.state.entity_id,
7092 npc_id,
7093 seq: self.seq,
7094 })
7095 .await?;
7096 self.state.intents_sent += 1;
7097 }
7098 Ok(())
7099 }
7100
7101 pub async fn storage_store(
7102 &mut self,
7103 item_instance_id: uuid::Uuid,
7104 quantity: Option<u32>,
7105 ) -> anyhow::Result<()> {
7106 let Some(panel) = self.state.storage_panel.clone() else {
7107 return Ok(());
7108 };
7109 self.seq += 1;
7110 self.session
7111 .submit_intent(Intent::StorageStore {
7112 entity_id: self.state.entity_id,
7113 npc_id: panel.npc_id,
7114 item_instance_id,
7115 quantity,
7116 seq: self.seq,
7117 })
7118 .await?;
7119 self.state.intents_sent += 1;
7120 Ok(())
7121 }
7122
7123 pub async fn storage_take(
7124 &mut self,
7125 item_instance_id: uuid::Uuid,
7126 quantity: Option<u32>,
7127 ) -> anyhow::Result<()> {
7128 let Some(panel) = self.state.storage_panel.clone() else {
7129 return Ok(());
7130 };
7131 self.seq += 1;
7132 self.session
7133 .submit_intent(Intent::StorageTake {
7134 entity_id: self.state.entity_id,
7135 npc_id: panel.npc_id,
7136 item_instance_id,
7137 quantity,
7138 seq: self.seq,
7139 })
7140 .await?;
7141 self.state.intents_sent += 1;
7142 Ok(())
7143 }
7144
7145 pub async fn storage_ship(
7146 &mut self,
7147 dest_building_id: String,
7148 item_instance_id: uuid::Uuid,
7149 quantity: Option<u32>,
7150 ) -> anyhow::Result<()> {
7151 let Some(panel) = self.state.storage_panel.clone() else {
7152 return Ok(());
7153 };
7154 self.seq += 1;
7155 self.session
7156 .submit_intent(Intent::StorageShip {
7157 entity_id: self.state.entity_id,
7158 npc_id: panel.npc_id,
7159 dest_building_id,
7160 item_instance_id,
7161 quantity,
7162 seq: self.seq,
7163 })
7164 .await?;
7165 self.state.intents_sent += 1;
7166 Ok(())
7167 }
7168
7169 pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
7170 let npc_id = self
7171 .state
7172 .storage_panel
7173 .as_ref()
7174 .map(|p| p.npc_id.clone());
7175 self.state.clear_storage_panel();
7176 if let Some(npc_id) = npc_id {
7177 self.seq += 1;
7178 self.session
7179 .submit_intent(Intent::StorageClose {
7180 entity_id: self.state.entity_id,
7181 npc_id,
7182 seq: self.seq,
7183 })
7184 .await?;
7185 self.state.intents_sent += 1;
7186 }
7187 Ok(())
7188 }
7189
7190 pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
7192 let return_to_verbs = self.state.npc_verb_target.is_some();
7193 self.close_shop_menu().await?;
7194 if return_to_verbs {
7195 self.state.show_npc_verb_menu = true;
7196 }
7197 Ok(())
7198 }
7199
7200 pub fn shop_tab_toggle(&mut self) {
7201 self.state.shop_tab = match self.state.shop_tab {
7202 ShopTab::Buy => ShopTab::Sell,
7203 ShopTab::Sell => ShopTab::Buy,
7204 };
7205 self.state.shop_menu_index = 0;
7206 if self.state.shop_tab == ShopTab::Sell {
7207 self.state.shop_quantity_set_max();
7208 }
7209 self.state.clamp_shop_selection();
7210 }
7211
7212 pub fn shop_menu_move(&mut self, delta: i32) {
7213 self.state.shop_menu_move(delta);
7214 }
7215
7216 pub fn shop_quantity_adjust(&mut self, delta: i32) {
7217 self.state.shop_quantity_adjust(delta);
7218 }
7219
7220 pub fn shop_quantity_set_max(&mut self) {
7221 self.state.shop_quantity_set_max();
7222 }
7223
7224 pub fn toggle_quest_menu(&mut self) {
7225 self.state.show_quest_menu = !self.state.show_quest_menu;
7226 if self.state.show_quest_menu {
7227 self.state.quest_menu_index = 0;
7228 self.state.quest_withdraw_confirm = false;
7229 self.state.show_workers_menu = false;
7230 }
7231 }
7232
7233 pub fn toggle_workers_menu(&mut self) {
7234 self.state.show_workers_menu = !self.state.show_workers_menu;
7235 if self.state.show_workers_menu {
7236 self.state.workers_menu_index = 0;
7237 self.state.show_quest_menu = false;
7238 self.close_worker_give_picker();
7239 self.close_worker_give_target_picker();
7240 self.close_worker_take_picker();
7241 self.close_worker_teach_picker();
7242 self.cancel_worker_rename();
7243 } else {
7244 self.close_worker_give_picker();
7245 self.close_worker_give_target_picker();
7246 self.close_worker_take_picker();
7247 self.close_worker_teach_picker();
7248 self.cancel_worker_rename();
7249 }
7250 }
7251
7252 pub fn workers_menu_move(&mut self, delta: i32) {
7253 let n = self.state.hired_workers.len();
7254 if n == 0 {
7255 return;
7256 }
7257 let idx = self.state.workers_menu_index as i32;
7258 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
7259 }
7260
7261 pub fn toggle_workers_menu_compact(&mut self) {
7262 self.state.workers_menu_compact = !self.state.workers_menu_compact;
7263 let mut cfg = crate::client_config::ClientConfig::load();
7264 let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
7265 }
7266
7267 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
7268 let Some(worker) = self
7269 .state
7270 .hired_workers
7271 .get(self.state.workers_menu_index)
7272 .cloned()
7273 else {
7274 anyhow::bail!("no worker selected");
7275 };
7276 self.seq += 1;
7277 self.session
7278 .submit_intent(Intent::DismissWorker {
7279 entity_id: self.state.entity_id,
7280 worker_instance_id: worker.instance_id.clone(),
7281 seq: self.seq,
7282 })
7283 .await?;
7284 self.state.intents_sent += 1;
7285 self.state
7286 .hired_workers
7287 .retain(|w| w.instance_id != worker.instance_id);
7288 if self.state.workers_menu_index >= self.state.hired_workers.len() {
7289 self.state.workers_menu_index = self
7290 .state
7291 .hired_workers
7292 .len()
7293 .saturating_sub(1);
7294 }
7295 self.state.push_log(format!("Dismissed {}", worker.label));
7296 Ok(())
7297 }
7298
7299 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
7300 let Some(worker) = self
7301 .state
7302 .hired_workers
7303 .get(self.state.workers_menu_index)
7304 .cloned()
7305 else {
7306 anyhow::bail!("no worker selected");
7307 };
7308 let mode = match worker.mode {
7309 flatland_protocol::WorkerModeView::Companion => "job_loop",
7310 flatland_protocol::WorkerModeView::JobLoop => "idle",
7311 flatland_protocol::WorkerModeView::Idle => "companion",
7312 };
7313 self.seq += 1;
7314 self.session
7315 .submit_intent(Intent::SetWorkerMode {
7316 entity_id: self.state.entity_id,
7317 worker_instance_id: worker.instance_id,
7318 mode: mode.into(),
7319 seq: self.seq,
7320 })
7321 .await?;
7322 self.state.intents_sent += 1;
7323 Ok(())
7324 }
7325
7326 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
7327 if self.state.hired_workers.is_empty() {
7328 return self.hire_worker_laborer().await;
7329 }
7330 self.workers_toggle_mode_selected().await
7331 }
7332
7333 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
7336 let row = self
7337 .state
7338 .inventory_selected_row()
7339 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
7340 .clone();
7341 if row.from != flatland_protocol::InventoryLocation::Root {
7342 anyhow::bail!("select a carried item to give");
7343 }
7344 let Some(instance_id) = row.stack.item_instance_id else {
7345 anyhow::bail!("that stack can't be given");
7346 };
7347 let options = self.nearby_worker_give_targets();
7348 if options.is_empty() {
7349 anyhow::bail!(
7350 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
7351 );
7352 }
7353 let item_label = row
7354 .stack
7355 .display_name
7356 .as_deref()
7357 .unwrap_or(&row.stack.template_id)
7358 .to_string();
7359 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
7360 item_instance_id: instance_id,
7361 item_label,
7362 quantity: None,
7363 options,
7364 });
7365 self.state.worker_give_target_picker_index = 0;
7366 self.state.show_worker_give_target_picker = true;
7367 self.state.show_inventory_menu = false;
7369 Ok(())
7370 }
7371
7372 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
7374 let (px, py, _) = self.state.player_position_with_z();
7375 let mut options: Vec<WorkerGiveTargetOption> = self
7376 .state
7377 .hired_workers
7378 .iter()
7379 .filter_map(|w| {
7380 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
7381 if dist > WORKER_GIVE_RANGE_M {
7382 return None;
7383 }
7384 Some(WorkerGiveTargetOption {
7385 instance_id: w.instance_id.clone(),
7386 label: w.label.clone(),
7387 distance_m: dist,
7388 })
7389 })
7390 .collect();
7391 options.sort_by(|a, b| {
7392 a.distance_m
7393 .partial_cmp(&b.distance_m)
7394 .unwrap_or(std::cmp::Ordering::Equal)
7395 });
7396 options
7397 }
7398
7399 pub fn close_worker_give_target_picker(&mut self) {
7400 self.state.show_worker_give_target_picker = false;
7401 self.state.worker_give_target_picker = None;
7402 self.state.worker_give_target_picker_index = 0;
7403 }
7404
7405 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
7406 let Some(picker) = &self.state.worker_give_target_picker else {
7407 return;
7408 };
7409 let n = picker.options.len();
7410 if n == 0 {
7411 return;
7412 }
7413 let idx = self.state.worker_give_target_picker_index as i32;
7414 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7415 }
7416
7417 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
7418 let Some(picker) = self.state.worker_give_target_picker.clone() else {
7419 anyhow::bail!("give target picker not open");
7420 };
7421 let Some(opt) = picker
7422 .options
7423 .get(self.state.worker_give_target_picker_index)
7424 .cloned()
7425 else {
7426 anyhow::bail!("no worker selected");
7427 };
7428 let Some(worker) = self
7429 .state
7430 .hired_workers
7431 .iter()
7432 .find(|w| w.instance_id == opt.instance_id)
7433 .cloned()
7434 else {
7435 self.close_worker_give_target_picker();
7436 anyhow::bail!("worker no longer hired");
7437 };
7438 self.give_item_to_worker(
7439 &worker.instance_id,
7440 &worker.label,
7441 worker.x,
7442 worker.y,
7443 picker.item_instance_id,
7444 &picker.item_label,
7445 picker.quantity,
7446 )
7447 .await?;
7448 self.close_worker_give_target_picker();
7449 Ok(())
7450 }
7451
7452 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
7454 self.open_worker_give_target_picker()
7455 }
7456
7457 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
7459 let Some(worker) = self
7460 .state
7461 .hired_workers
7462 .get(self.state.workers_menu_index)
7463 .cloned()
7464 else {
7465 anyhow::bail!("select a hired worker first");
7466 };
7467 let (px, py, _) = self.state.player_position_with_z();
7468 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7469 if dist > WORKER_GIVE_RANGE_M {
7470 anyhow::bail!(
7471 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
7472 worker.label
7473 );
7474 }
7475 let options = self.state.giveable_inventory_options();
7476 if options.is_empty() {
7477 anyhow::bail!("nothing in inventory to give");
7478 }
7479 self.state.worker_give_picker = Some(WorkerGivePicker {
7480 worker_instance_id: worker.instance_id,
7481 worker_label: worker.label,
7482 options,
7483 });
7484 self.state.worker_give_picker_index = 0;
7485 self.state.show_worker_give_picker = true;
7486 Ok(())
7487 }
7488
7489 pub fn close_worker_give_picker(&mut self) {
7490 self.state.show_worker_give_picker = false;
7491 self.state.worker_give_picker = None;
7492 self.state.worker_give_picker_index = 0;
7493 }
7494
7495 pub fn worker_give_picker_move(&mut self, delta: i32) {
7496 let Some(picker) = &self.state.worker_give_picker else {
7497 return;
7498 };
7499 let n = picker.options.len();
7500 if n == 0 {
7501 return;
7502 }
7503 let idx = self.state.worker_give_picker_index as i32;
7504 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7505 }
7506
7507 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
7509 let Some(picker) = self.state.worker_give_picker.clone() else {
7510 anyhow::bail!("give picker not open");
7511 };
7512 let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
7513 anyhow::bail!("no item selected");
7514 };
7515 let Some(worker) = self
7516 .state
7517 .hired_workers
7518 .iter()
7519 .find(|w| w.instance_id == picker.worker_instance_id)
7520 .cloned()
7521 else {
7522 self.close_worker_give_picker();
7523 anyhow::bail!("worker no longer hired");
7524 };
7525 self.give_item_to_worker(
7526 &worker.instance_id,
7527 &worker.label,
7528 worker.x,
7529 worker.y,
7530 opt.item_instance_id,
7531 &opt.label,
7532 None,
7533 )
7534 .await?;
7535 let options = self.state.giveable_inventory_options();
7537 if options.is_empty() {
7538 self.close_worker_give_picker();
7539 } else {
7540 self.state.worker_give_picker = Some(WorkerGivePicker {
7541 worker_instance_id: picker.worker_instance_id,
7542 worker_label: picker.worker_label,
7543 options,
7544 });
7545 if self.state.worker_give_picker_index
7546 >= self
7547 .state
7548 .worker_give_picker
7549 .as_ref()
7550 .map(|p| p.options.len())
7551 .unwrap_or(0)
7552 {
7553 self.state.worker_give_picker_index = self
7554 .state
7555 .worker_give_picker
7556 .as_ref()
7557 .map(|p| p.options.len().saturating_sub(1))
7558 .unwrap_or(0);
7559 }
7560 }
7561 Ok(())
7562 }
7563
7564 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
7566 let Some(worker) = self
7567 .state
7568 .hired_workers
7569 .get(self.state.workers_menu_index)
7570 .cloned()
7571 else {
7572 anyhow::bail!("select a hired worker first");
7573 };
7574 let (px, py, _) = self.state.player_position_with_z();
7575 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7576 if dist > WORKER_GIVE_RANGE_M {
7577 anyhow::bail!(
7578 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
7579 worker.label
7580 );
7581 }
7582 let options = self.state.teachable_blueprint_options(&worker);
7583 if options.is_empty() {
7584 anyhow::bail!("no recipes you know that {} still needs", worker.label);
7585 }
7586 self.state.worker_teach_picker = Some(WorkerTeachPicker {
7587 worker_instance_id: worker.instance_id,
7588 worker_label: worker.label,
7589 worker_level: worker.level,
7590 options,
7591 });
7592 self.state.worker_teach_picker_index = 0;
7593 self.state.show_worker_teach_picker = true;
7594 Ok(())
7595 }
7596
7597 pub fn close_worker_teach_picker(&mut self) {
7598 self.state.show_worker_teach_picker = false;
7599 self.state.worker_teach_picker = None;
7600 self.state.worker_teach_picker_index = 0;
7601 }
7602
7603 pub fn worker_teach_picker_move(&mut self, delta: i32) {
7604 let Some(picker) = &self.state.worker_teach_picker else {
7605 return;
7606 };
7607 let n = picker.options.len();
7608 if n == 0 {
7609 return;
7610 }
7611 let idx = self.state.worker_teach_picker_index as i32;
7612 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7613 }
7614
7615 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
7616 let Some(picker) = self.state.worker_teach_picker.clone() else {
7617 anyhow::bail!("teach picker not open");
7618 };
7619 let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
7620 anyhow::bail!("nothing selected");
7621 };
7622 if !opt.level_ok {
7623 anyhow::bail!(
7624 "{} needs level {} (is level {})",
7625 picker.worker_label,
7626 opt.min_level,
7627 opt.worker_level
7628 );
7629 }
7630 if !opt.can_afford {
7631 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
7632 }
7633 let Some(worker) = self
7634 .state
7635 .hired_workers
7636 .iter()
7637 .find(|w| w.instance_id == picker.worker_instance_id)
7638 .cloned()
7639 else {
7640 anyhow::bail!("worker gone");
7641 };
7642 let (px, py, _) = self.state.player_position_with_z();
7643 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7644 if dist > WORKER_GIVE_RANGE_M {
7645 anyhow::bail!("worker {} too far — stand next to them", worker.label);
7646 }
7647 self.seq += 1;
7648 self.session
7649 .submit_intent(Intent::TeachWorkerBlueprint {
7650 entity_id: self.state.entity_id,
7651 worker_instance_id: picker.worker_instance_id.clone(),
7652 blueprint_id: opt.blueprint_id.clone(),
7653 seq: self.seq,
7654 })
7655 .await?;
7656 self.state.intents_sent += 1;
7657 self.state.push_log(format!(
7658 "Teaching {} to {} ({} cp)",
7659 opt.label, picker.worker_label, opt.cost_copper
7660 ));
7661 self.close_worker_teach_picker();
7662 Ok(())
7663 }
7664
7665 async fn give_item_to_worker(
7666 &mut self,
7667 worker_instance_id: &str,
7668 worker_label: &str,
7669 worker_x: f32,
7670 worker_y: f32,
7671 item_instance_id: uuid::Uuid,
7672 item_label: &str,
7673 quantity: Option<u32>,
7674 ) -> anyhow::Result<()> {
7675 let (px, py, _) = self.state.player_position_with_z();
7676 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
7677 if dist > WORKER_GIVE_RANGE_M {
7678 anyhow::bail!("worker {worker_label} too far — stand next to them");
7679 }
7680 self.seq += 1;
7681 self.session
7682 .submit_intent(Intent::GiveWorkerItem {
7683 entity_id: self.state.entity_id,
7684 worker_instance_id: worker_instance_id.to_string(),
7685 item_instance_id,
7686 quantity,
7687 seq: self.seq,
7688 })
7689 .await?;
7690 self.state.intents_sent += 1;
7691 self.state
7692 .push_log(format!("Gave {item_label} to {worker_label}"));
7693 Ok(())
7694 }
7695
7696 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
7698 let Some(worker) = self
7699 .state
7700 .hired_workers
7701 .get(self.state.workers_menu_index)
7702 .cloned()
7703 else {
7704 anyhow::bail!("select a hired worker first");
7705 };
7706 let (px, py, _) = self.state.player_position_with_z();
7707 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7708 if dist > WORKER_GIVE_RANGE_M {
7709 anyhow::bail!(
7710 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
7711 worker.label
7712 );
7713 }
7714 let options = Self::worker_inventory_options(&worker);
7715 if options.is_empty() {
7716 anyhow::bail!("{} isn't carrying anything", worker.label);
7717 }
7718 self.state.worker_take_picker = Some(WorkerTakePicker {
7719 worker_instance_id: worker.instance_id,
7720 worker_label: worker.label,
7721 options,
7722 });
7723 self.state.worker_take_picker_index = 0;
7724 self.state.show_worker_take_picker = true;
7725 Ok(())
7726 }
7727
7728 fn worker_inventory_options(
7729 worker: &flatland_protocol::HiredWorkerView,
7730 ) -> Vec<WorkerGiveOption> {
7731 worker
7732 .inventory
7733 .iter()
7734 .filter_map(|stack| {
7735 let item_instance_id = stack.item_instance_id?;
7736 let label = stack
7737 .display_name
7738 .clone()
7739 .unwrap_or_else(|| stack.template_id.clone());
7740 let label = if stack.quantity > 1 {
7741 format!("{label} ×{}", stack.quantity)
7742 } else {
7743 label
7744 };
7745 Some(WorkerGiveOption {
7746 item_instance_id,
7747 label,
7748 quantity: stack.quantity,
7749 template_id: stack.template_id.clone(),
7750 })
7751 })
7752 .collect()
7753 }
7754
7755 pub fn close_worker_take_picker(&mut self) {
7756 self.state.show_worker_take_picker = false;
7757 self.state.worker_take_picker = None;
7758 self.state.worker_take_picker_index = 0;
7759 }
7760
7761 pub fn worker_take_picker_move(&mut self, delta: i32) {
7762 let Some(picker) = &self.state.worker_take_picker else {
7763 return;
7764 };
7765 let n = picker.options.len();
7766 if n == 0 {
7767 return;
7768 }
7769 let idx = self.state.worker_take_picker_index as i32;
7770 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7771 }
7772
7773 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
7774 let Some(picker) = self.state.worker_take_picker.clone() else {
7775 anyhow::bail!("take picker not open");
7776 };
7777 let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
7778 anyhow::bail!("no item selected");
7779 };
7780 let Some(worker) = self
7781 .state
7782 .hired_workers
7783 .iter()
7784 .find(|w| w.instance_id == picker.worker_instance_id)
7785 .cloned()
7786 else {
7787 self.close_worker_take_picker();
7788 anyhow::bail!("worker no longer hired");
7789 };
7790 self.take_item_from_worker(
7791 &worker.instance_id,
7792 &worker.label,
7793 worker.x,
7794 worker.y,
7795 opt.item_instance_id,
7796 &opt.label,
7797 None,
7798 )
7799 .await?;
7800 if let Some(w) = self
7804 .state
7805 .hired_workers
7806 .iter()
7807 .find(|w| w.instance_id == picker.worker_instance_id)
7808 {
7809 let options = Self::worker_inventory_options(w)
7810 .into_iter()
7811 .filter(|o| o.item_instance_id != opt.item_instance_id)
7812 .collect::<Vec<_>>();
7813 if options.is_empty() {
7814 self.close_worker_take_picker();
7815 } else {
7816 self.state.worker_take_picker = Some(WorkerTakePicker {
7817 worker_instance_id: picker.worker_instance_id,
7818 worker_label: picker.worker_label,
7819 options,
7820 });
7821 self.state.worker_take_picker_index = self
7822 .state
7823 .worker_take_picker_index
7824 .min(
7825 self.state
7826 .worker_take_picker
7827 .as_ref()
7828 .map(|p| p.options.len().saturating_sub(1))
7829 .unwrap_or(0),
7830 );
7831 }
7832 } else {
7833 self.close_worker_take_picker();
7834 }
7835 Ok(())
7836 }
7837
7838 async fn take_item_from_worker(
7839 &mut self,
7840 worker_instance_id: &str,
7841 worker_label: &str,
7842 worker_x: f32,
7843 worker_y: f32,
7844 item_instance_id: uuid::Uuid,
7845 item_label: &str,
7846 quantity: Option<u32>,
7847 ) -> anyhow::Result<()> {
7848 let (px, py, _) = self.state.player_position_with_z();
7849 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
7850 if dist > WORKER_GIVE_RANGE_M {
7851 anyhow::bail!("worker {worker_label} too far — stand next to them");
7852 }
7853 self.seq += 1;
7854 self.session
7855 .submit_intent(Intent::TakeWorkerItem {
7856 entity_id: self.state.entity_id,
7857 worker_instance_id: worker_instance_id.to_string(),
7858 item_instance_id,
7859 quantity,
7860 seq: self.seq,
7861 })
7862 .await?;
7863 self.state.intents_sent += 1;
7864 self.state
7865 .push_log(format!("Took {item_label} from {worker_label}"));
7866 Ok(())
7867 }
7868
7869 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
7870 if !self.state.has_worker_lodging() {
7871 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
7872 }
7873 self.seq += 1;
7874 self.session
7875 .submit_intent(Intent::HireWorker {
7876 entity_id: self.state.entity_id,
7877 def_id: "worker_laborer".into(),
7878 wage_copper_per_interval: 8,
7879 lodging_container_id: None,
7880 job_yaml: None,
7881 seq: self.seq,
7882 })
7883 .await?;
7884 self.state.intents_sent += 1;
7885 Ok(())
7886 }
7887
7888 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
7889 let Some(worker) = self
7890 .state
7891 .hired_workers
7892 .get(self.state.workers_menu_index)
7893 .cloned()
7894 else {
7895 anyhow::bail!("select a hired worker first");
7896 };
7897 let lodging = worker.lodging_container_id.clone().or_else(|| {
7898 crate::worker_route_editor::owned_lodging_container_ids(
7899 &self.state.placed_containers,
7900 self.state.character_id,
7901 )
7902 .into_iter()
7903 .next()
7904 .map(|(id, _)| id)
7905 });
7906 let label = worker.label.clone();
7907 let editor = if let Some(route) = &worker.route {
7908 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
7909 worker.instance_id,
7910 worker.label,
7911 route,
7912 lodging,
7913 )
7914 } else {
7915 crate::worker_route_editor::WorkerRouteEditorState::new(
7916 worker.instance_id,
7917 worker.label,
7918 lodging,
7919 )
7920 };
7921 self.state.worker_route_editor = Some(editor);
7922 if let Some(ed) = self.state.worker_route_editor.as_mut() {
7923 if let Some(collapsed) =
7924 crate::client_config::ClientConfig::load().worker_route_panel_collapsed
7925 {
7926 ed.panel_collapsed = collapsed;
7927 }
7928 }
7929 self.state.show_workers_menu = false;
7930 self.state.push_log(format!(
7931 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
7932 ));
7933 Ok(())
7934 }
7935
7936 pub fn close_worker_route_editor(&mut self) {
7937 self.state.worker_route_editor = None;
7938 }
7939
7940 pub fn worker_route_editor_toggle_panel(&mut self) {
7941 if let Some(ed) = self.state.worker_route_editor.as_mut() {
7942 ed.toggle_panel_collapsed();
7943 let collapsed = ed.panel_collapsed;
7944 let mut cfg = crate::client_config::ClientConfig::load();
7945 let _ = cfg.save_worker_route_panel_collapsed(collapsed);
7946 }
7947 }
7948
7949 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
7950 let n = {
7951 let Some(ed) = self.state.worker_route_editor.as_mut() else {
7952 return;
7953 };
7954 ed.append_waypoint(x, y, z);
7955 ed.stop_count()
7956 };
7957 self.state
7958 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
7959 }
7960
7961 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
7964 let (px, py, _) = self.state.player_position_with_z();
7965 crate::worker_route_editor::owned_container_candidates(
7966 &self.state.placed_containers,
7967 self.state.character_id,
7968 px,
7969 py,
7970 )
7971 }
7972
7973 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
7974 let (px, py, _) = self.state.player_position_with_z();
7975 crate::worker_route_editor::node_candidates(&self.state.resource_nodes, px, py)
7976 }
7977
7978 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
7979 let (px, py, _) = self.state.player_position_with_z();
7980 crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
7981 }
7982
7983 fn re_template_candidates(&self) -> Vec<String> {
7984 let mut extra = Vec::new();
7985 if let Some(ed) = self.state.worker_route_editor.as_ref() {
7986 for stop in &ed.stops {
7987 match stop {
7988 crate::worker_route_editor::WorkerRouteStop::DepositAt {
7989 filter: Some(filter),
7990 ..
7991 } => extra.extend(filter.iter().cloned()),
7992 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
7993 extra.push(template.clone());
7994 }
7995 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
7996 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
7997 extra.push(bp.output.clone());
7998 for input in &bp.inputs {
7999 extra.push(input.template_id.clone());
8000 }
8001 }
8002 }
8003 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
8004 for it in items {
8005 extra.push(it.template.clone());
8006 }
8007 }
8008 _ => {}
8009 }
8010 }
8011 if let Some(worker) = self
8013 .state
8014 .hired_workers
8015 .iter()
8016 .find(|w| w.instance_id == ed.worker_instance_id)
8017 {
8018 for recipe in &worker.known_blueprint_ids {
8019 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
8020 extra.push(bp.output.clone());
8021 }
8022 }
8023 }
8024 }
8025 crate::worker_route_editor::route_item_template_candidates(
8026 &self.state.placed_containers,
8027 self.state.character_id,
8028 &self.state.inventory,
8029 &self.state.blueprints,
8030 &self.state.resource_nodes,
8031 &extra,
8032 )
8033 }
8034
8035 fn re_blueprint_ids(&self) -> Vec<String> {
8036 let worker_known: Option<&[String]> = self
8037 .state
8038 .worker_route_editor
8039 .as_ref()
8040 .and_then(|ed| {
8041 self.state
8042 .hired_workers
8043 .iter()
8044 .find(|w| w.instance_id == ed.worker_instance_id)
8045 })
8046 .map(|w| w.known_blueprint_ids.as_slice());
8047 crate::worker_route_editor::worker_craft_blueprint_ids(
8048 &self.state.blueprints,
8049 worker_known,
8050 )
8051 }
8052
8053 fn re_bed_candidates(&self) -> Vec<(String, String)> {
8054 crate::worker_route_editor::owned_lodging_container_ids(
8055 &self.state.placed_containers,
8056 self.state.character_id,
8057 )
8058 }
8059
8060 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
8061 self.state
8062 .placed_containers
8063 .iter()
8064 .find(|c| c.id == container_id)
8065 .map(|c| c.contents.clone())
8066 .unwrap_or_default()
8067 }
8068
8069 pub fn re_sheet_row_count(&self) -> usize {
8073 use crate::worker_route_editor::RouteEditorSheet as S;
8074 let Some(ed) = self.state.worker_route_editor.as_ref() else {
8075 return 0;
8076 };
8077 match &ed.sheet {
8078 S::Stops => ed.stops.len(),
8079 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
8080 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
8081 S::WaypointMapPick => 0,
8082 S::HarvestPicker { .. } => self.re_node_candidates().len(),
8083 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
8084 self.re_container_candidates().len()
8085 }
8086 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(),
8091 S::WaitEntry { .. } => 1,
8092 S::BedPicker { .. } => self.re_bed_candidates().len(),
8093 }
8094 }
8095
8096 pub fn re_sheet_index(&self) -> usize {
8098 use crate::worker_route_editor::RouteEditorSheet as S;
8099 let Some(ed) = self.state.worker_route_editor.as_ref() else {
8100 return 0;
8101 };
8102 match &ed.sheet {
8103 S::AddMenu { index }
8104 | S::WaypointMenu { index }
8105 | S::HarvestPicker { index }
8106 | S::WithdrawContainers { index }
8107 | S::DepositContainers { index }
8108 | S::SellNpcs { index }
8109 | S::CraftBlueprint { index }
8110 | S::BedPicker { index }
8111 | S::WithdrawItems { index, .. }
8112 | S::DepositFilter { index, .. }
8113 | S::SellItem { index, .. } => *index,
8114 _ => 0,
8115 }
8116 }
8117
8118 pub fn re_sheet_move(&mut self, delta: i32) {
8120 use crate::worker_route_editor::RouteEditorSheet as S;
8121 let count = self.re_sheet_row_count();
8122 if count == 0 {
8123 return;
8124 }
8125 let cur = self.re_sheet_index() as i32;
8126 let next = (cur + delta).rem_euclid(count as i32) as usize;
8127 let Some(ed) = self.state.worker_route_editor.as_mut() else {
8128 return;
8129 };
8130 match &mut ed.sheet {
8131 S::AddMenu { index }
8132 | S::WaypointMenu { index }
8133 | S::HarvestPicker { index }
8134 | S::WithdrawContainers { index }
8135 | S::DepositContainers { index }
8136 | S::SellNpcs { index }
8137 | S::CraftBlueprint { index }
8138 | S::BedPicker { index }
8139 | S::WithdrawItems { index, .. }
8140 | S::DepositFilter { index, .. }
8141 | S::SellItem { index, .. } => *index = next,
8142 _ => {}
8143 }
8144 }
8145
8146 pub fn re_sheet_adjust(&mut self, delta: i32) {
8148 use crate::worker_route_editor::RouteEditorSheet as S;
8149 let index = self.re_sheet_index();
8150 let Some(ed) = self.state.worker_route_editor.as_mut() else {
8151 return;
8152 };
8153 match &mut ed.sheet {
8154 S::WithdrawItems { lines, .. } => {
8155 if let Some(line) = lines.get_mut(index) {
8156 line.adjust_qty(delta);
8157 }
8158 }
8159 S::WaitEntry { ticks } => {
8160 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
8161 }
8162 _ => {}
8163 }
8164 }
8165
8166 pub fn re_sheet_back(&mut self) {
8167 let Some(ed) = self.state.worker_route_editor.as_mut() else {
8168 return;
8169 };
8170 use crate::worker_route_editor::RouteEditorSheet as S;
8171 let was_editing = ed.editing_index.is_some();
8172 let from_top_picker = matches!(
8173 ed.sheet,
8174 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
8175 );
8176 ed.sheet_back();
8177 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
8178 self.state
8180 .push_log("Route: left edit sheet — press s to save current stops".to_string());
8181 }
8182 }
8183
8184 pub fn re_at_root_sheet(&self) -> bool {
8186 self.state
8187 .worker_route_editor
8188 .as_ref()
8189 .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
8190 }
8191
8192 pub fn re_open_add_menu(&mut self) {
8193 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8194 ed.open_add_menu();
8195 }
8196 }
8197
8198 pub fn re_open_bed_picker(&mut self) {
8199 let beds = self.re_bed_candidates();
8200 if beds.is_empty() {
8201 self.state
8202 .push_log("Route: place a camp bed first".to_string());
8203 return;
8204 }
8205 let current = self
8206 .state
8207 .worker_route_editor
8208 .as_ref()
8209 .and_then(|ed| ed.lodging_container_id.clone());
8210 let index = current
8211 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
8212 .unwrap_or(0);
8213 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
8214 }
8215
8216 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
8217 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8218 ed.open_sheet(sheet);
8219 }
8220 }
8221
8222 fn re_confirm_stop(
8224 &mut self,
8225 stop: crate::worker_route_editor::WorkerRouteStop,
8226 what: String,
8227 ) {
8228 let appended = self
8229 .state
8230 .worker_route_editor
8231 .as_mut()
8232 .is_some_and(|ed| ed.confirm_stop(stop));
8233 if appended {
8234 self.state.push_log(format!("Route: + {what}"));
8235 } else {
8236 self.state
8237 .push_log(format!("Route: {what} already in route — selected it"));
8238 }
8239 }
8240
8241 fn re_open_withdraw_items(&mut self, container_id: String) {
8242 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
8243 let contents = self.re_container_contents(&container_id);
8244 let existing = self
8248 .state
8249 .worker_route_editor
8250 .as_ref()
8251 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
8252 .and_then(|stop| match stop {
8253 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
8254 _ => None,
8255 })
8256 .unwrap_or_default();
8257 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
8258 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8261 let _ = ed.retarget_withdraw_container(container_id.clone());
8262 }
8263 self.re_open_sheet(S::WithdrawItems {
8264 container_id,
8265 lines,
8266 index: 0,
8267 });
8268 }
8269
8270 fn re_withdraw_items_activate(&mut self, index: usize) {
8271 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
8272 enum Outcome {
8273 Cycled,
8274 Confirmed(String),
8275 Empty,
8276 }
8277 let outcome = {
8278 let Some(ed) = self.state.worker_route_editor.as_mut() else {
8279 return;
8280 };
8281 let S::WithdrawItems {
8282 container_id,
8283 lines,
8284 index: sheet_index,
8285 } = &mut ed.sheet
8286 else {
8287 return;
8288 };
8289 *sheet_index = index;
8290 if index < lines.len() {
8291 lines[index].cycle();
8292 Outcome::Cycled
8293 } else {
8294 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
8295 if items.is_empty() {
8296 Outcome::Empty
8297 } else {
8298 let stop = WorkerRouteStop::WithdrawFrom {
8299 container_id: container_id.clone(),
8300 items,
8301 };
8302 let summary = stop.summary();
8303 ed.confirm_stop(stop);
8304 Outcome::Confirmed(summary)
8305 }
8306 }
8307 };
8308 match outcome {
8309 Outcome::Cycled => {}
8310 Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
8311 Outcome::Empty => self
8312 .state
8313 .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
8314 }
8315 }
8316
8317 fn re_open_deposit_filter(&mut self, container_id: String) {
8318 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8319 let existing_filter = self
8321 .state
8322 .worker_route_editor
8323 .as_ref()
8324 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
8325 .and_then(|stop| match stop {
8326 WorkerRouteStop::DepositAt { filter, .. } => {
8327 Some(filter.clone().unwrap_or_default())
8328 }
8329 _ => None,
8330 });
8331 let mut candidates = self.re_template_candidates();
8332 if let Some(ref chosen) = existing_filter {
8333 for t in chosen {
8334 if !candidates.iter().any(|c| c == t) {
8335 candidates.push(t.clone());
8336 }
8337 }
8338 candidates.sort();
8339 candidates.dedup();
8340 }
8341 let rows: Vec<(String, bool)> = match existing_filter {
8342 Some(chosen) => candidates
8343 .iter()
8344 .map(|t| (t.clone(), chosen.contains(t)))
8345 .collect(),
8346 None => candidates.into_iter().map(|t| (t, false)).collect(),
8347 };
8348 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8349 let _ = ed.retarget_deposit_container(container_id.clone());
8350 }
8351 self.re_open_sheet(S::DepositFilter {
8352 container_id,
8353 rows,
8354 index: 0,
8355 });
8356 }
8357
8358 fn re_deposit_filter_activate(&mut self, index: usize) {
8359 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8360 let mut confirmed: Option<String> = None;
8361 {
8362 let Some(ed) = self.state.worker_route_editor.as_mut() else {
8363 return;
8364 };
8365 let S::DepositFilter {
8366 container_id,
8367 rows,
8368 index: sheet_index,
8369 } = &mut ed.sheet
8370 else {
8371 return;
8372 };
8373 *sheet_index = index;
8374 if index < rows.len() {
8375 rows[index].1 = !rows[index].1;
8376 } else {
8377 let chosen: Vec<String> = rows
8379 .iter()
8380 .filter(|(_, on)| *on)
8381 .map(|(t, _)| t.clone())
8382 .collect();
8383 let filter = if chosen.is_empty() { None } else { Some(chosen) };
8384 let stop = WorkerRouteStop::DepositAt {
8385 container_id: container_id.clone(),
8386 filter,
8387 };
8388 confirmed = Some(stop.summary());
8389 ed.confirm_stop(stop);
8390 }
8391 }
8392 if let Some(what) = confirmed {
8393 self.state.push_log(format!("Route: + {what}"));
8394 }
8395 }
8396
8397 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
8398 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8399 let templates = self.re_template_candidates();
8400 if templates.is_empty() {
8401 self.state.push_log(
8402 "Route: no item templates available — learn a craft recipe or place a harvest node first"
8403 .to_string(),
8404 );
8405 return;
8406 }
8407 let (pre_npc, pre_template, pre_all) = self
8409 .state
8410 .worker_route_editor
8411 .as_ref()
8412 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
8413 .and_then(|stop| match stop {
8414 WorkerRouteStop::TradeWith {
8415 npc_id,
8416 template,
8417 sell_all,
8418 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
8419 _ => None,
8420 })
8421 .unwrap_or((None, None, true));
8422 let npc_id = npc_id.or(pre_npc);
8423 let index = pre_template
8424 .and_then(|t| templates.iter().position(|x| x == &t))
8425 .map(|i| i + 1) .unwrap_or(1);
8427 self.re_open_sheet(S::SellItem {
8428 npc_id,
8429 templates,
8430 index,
8431 sell_all: pre_all,
8432 });
8433 }
8434
8435 fn re_sell_item_activate(&mut self, index: usize) {
8436 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8437 let mut confirmed: Option<String> = None;
8438 {
8439 let Some(ed) = self.state.worker_route_editor.as_mut() else {
8440 return;
8441 };
8442 let S::SellItem {
8443 npc_id,
8444 templates,
8445 index: sheet_index,
8446 sell_all,
8447 } = &mut ed.sheet
8448 else {
8449 return;
8450 };
8451 *sheet_index = index;
8452 if index == 0 {
8453 *sell_all = !*sell_all;
8454 } else if let Some(template) = templates.get(index - 1).cloned() {
8455 let stop = WorkerRouteStop::TradeWith {
8456 npc_id: npc_id.clone(),
8457 template,
8458 sell_all: *sell_all,
8459 };
8460 confirmed = Some(stop.summary());
8461 ed.confirm_stop(stop);
8462 }
8463 }
8464 if let Some(what) = confirmed {
8465 self.state.push_log(format!("Route: + {what}"));
8466 }
8467 }
8468
8469 pub fn re_edit_selected_stop(&mut self) {
8471 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8472 let Some(stop) = self
8473 .state
8474 .worker_route_editor
8475 .as_ref()
8476 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
8477 else {
8478 self.state
8479 .push_log("Route: no stop selected — press a to add one".to_string());
8480 return;
8481 };
8482 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8483 ed.begin_edit_selected();
8484 }
8485 match stop {
8486 WorkerRouteStop::Waypoint { .. } => {
8487 self.re_open_sheet(S::WaypointMenu { index: 0 });
8488 }
8489 WorkerRouteStop::HarvestNode { node_id } => {
8490 let nodes = self.re_node_candidates();
8491 let index = nodes.iter().position(|n| n.id == node_id).unwrap_or(0);
8492 if nodes.is_empty() {
8493 self.re_cancel_edit();
8494 self.state
8495 .push_log("Route: no harvestable nodes visible to retarget".to_string());
8496 } else {
8497 self.re_open_sheet(S::HarvestPicker { index });
8498 }
8499 }
8500 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
8501 let containers = self.re_container_candidates();
8504 if containers.is_empty() {
8505 self.re_cancel_edit();
8506 self.state
8507 .push_log("Route: place a storage chest first".to_string());
8508 } else {
8509 let index = containers
8510 .iter()
8511 .position(|c| c.id == container_id)
8512 .unwrap_or(0);
8513 self.re_open_sheet(S::WithdrawContainers { index });
8514 }
8515 }
8516 WorkerRouteStop::DepositAt { container_id, .. } => {
8517 let containers = self.re_container_candidates();
8518 if containers.is_empty() {
8519 self.re_cancel_edit();
8520 self.state
8521 .push_log("Route: place a storage chest first".to_string());
8522 } else {
8523 let index = containers
8524 .iter()
8525 .position(|c| c.id == container_id)
8526 .unwrap_or(0);
8527 self.re_open_sheet(S::DepositContainers { index });
8528 }
8529 }
8530 WorkerRouteStop::TradeWith { npc_id, .. } => {
8531 let npcs = self.re_npc_candidates();
8532 let index = npc_id
8534 .as_ref()
8535 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
8536 .unwrap_or(0);
8537 self.re_open_sheet(S::SellNpcs { index });
8538 }
8539 WorkerRouteStop::CraftAt { blueprint, .. } => {
8540 let bps = self.re_blueprint_ids();
8541 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
8542 if bps.is_empty() {
8543 self.re_cancel_edit();
8544 self.state
8545 .push_log("Route: no known blueprints to retarget".to_string());
8546 } else {
8547 self.re_open_sheet(S::CraftBlueprint { index });
8548 }
8549 }
8550 WorkerRouteStop::RestIfNeeded => {
8551 self.re_cancel_edit();
8552 self.state
8553 .push_log("Route: rest has no settings (change the bed with l)".to_string());
8554 }
8555 WorkerRouteStop::Wait { wait_ticks } => {
8556 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
8557 }
8558 }
8559 }
8560
8561 fn re_cancel_edit(&mut self) {
8562 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8563 ed.editing_index = None;
8564 }
8565 }
8566
8567 pub fn worker_route_editor_ui_click(
8570 &mut self,
8571 click: crate::worker_route_editor::RouteEditorClick,
8572 ) {
8573 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
8574 match click {
8575 RouteEditorClick::SelectStop(i) => {
8576 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8577 ed.sheet = S::Stops;
8578 ed.select_stop(i);
8579 }
8580 }
8581 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
8582 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
8583 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
8584 }
8585 }
8586
8587 pub fn re_sheet_row_activate(&mut self, row: usize) {
8589 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8590 let Some(sheet) = self
8591 .state
8592 .worker_route_editor
8593 .as_ref()
8594 .map(|ed| ed.sheet.clone())
8595 else {
8596 return;
8597 };
8598 match sheet {
8599 S::Stops => {
8600 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8601 ed.select_stop(row);
8602 }
8603 }
8604 S::AddMenu { .. } => match row {
8605 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
8606 1 => {
8607 if self.re_node_candidates().is_empty() {
8608 self.state
8609 .push_log("Route: no harvestable nodes visible in this region".to_string());
8610 } else {
8611 self.re_open_sheet(S::HarvestPicker { index: 0 });
8612 }
8613 }
8614 2 | 3 => {
8615 if self.re_container_candidates().is_empty() {
8616 self.state
8617 .push_log("Route: place a storage chest first".to_string());
8618 } else if row == 2 {
8619 self.re_open_sheet(S::WithdrawContainers { index: 0 });
8620 } else {
8621 self.re_open_sheet(S::DepositContainers { index: 0 });
8622 }
8623 }
8624 4 => {
8625 if self.re_template_candidates().is_empty() {
8626 self.state.push_log(
8627 "Route: no item templates available — learn a craft recipe or place a harvest node first"
8628 .to_string(),
8629 );
8630 } else {
8631 self.re_open_sheet(S::SellNpcs { index: 0 });
8632 }
8633 }
8634 5 => {
8635 if self.re_blueprint_ids().is_empty() {
8636 self.state.push_log(
8637 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
8638 .to_string(),
8639 );
8640 } else {
8641 self.re_open_sheet(S::CraftBlueprint { index: 0 });
8642 }
8643 }
8644 6 => self.re_confirm_stop(
8645 WorkerRouteStop::RestIfNeeded,
8646 "rest if needed".into(),
8647 ),
8648 7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
8649 _ => {}
8650 },
8651 S::WaypointMenu { .. } => match row {
8652 0 => {
8653 let (x, y, z) = self.state.player_position_with_z();
8654 let stop = WorkerRouteStop::Waypoint { x, y, z };
8655 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
8656 }
8657 1 => {
8658 self.re_open_sheet(S::WaypointMapPick);
8659 self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
8660 }
8661 _ => {}
8662 },
8663 S::HarvestPicker { .. } => {
8664 let nodes = self.re_node_candidates();
8665 if let Some(n) = nodes.get(row) {
8666 let stop = WorkerRouteStop::HarvestNode {
8667 node_id: n.id.clone(),
8668 };
8669 let label = n.label.clone();
8670 self.re_confirm_stop(stop, format!("harvest {label}"));
8671 }
8672 }
8673 S::WithdrawContainers { .. } => {
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_withdraw_items(id);
8678 }
8679 }
8680 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
8681 S::DepositContainers { .. } => {
8682 let containers = self.re_container_candidates();
8683 if let Some(c) = containers.get(row) {
8684 let id = c.id.clone();
8685 self.re_open_deposit_filter(id);
8686 }
8687 }
8688 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
8689 S::SellNpcs { .. } => {
8690 let npcs = self.re_npc_candidates();
8691 let npc_id = if row == 0 {
8692 None
8693 } else {
8694 npcs.get(row - 1).map(|n| n.id.clone())
8695 };
8696 if row == 0 || npc_id.is_some() {
8697 self.re_open_sell_item(npc_id);
8698 }
8699 }
8700 S::SellItem { .. } => self.re_sell_item_activate(row),
8701 S::CraftBlueprint { .. } => {
8702 let bps = self.re_blueprint_ids();
8703 if let Some(bp) = bps.get(row) {
8704 let stop = WorkerRouteStop::CraftAt {
8705 device: "hand".into(),
8706 blueprint: bp.clone(),
8707 qty: None,
8708 };
8709 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
8710 }
8711 }
8712 S::WaitEntry { ticks } => {
8713 let stop = WorkerRouteStop::Wait {
8714 wait_ticks: ticks,
8715 };
8716 self.re_confirm_stop(stop, format!("wait {ticks}t"));
8717 }
8718 S::BedPicker { .. } => {
8719 let beds = self.re_bed_candidates();
8720 if let Some((id, name)) = beds.get(row) {
8721 let (id, name) = (id.clone(), name.clone());
8722 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8723 ed.lodging_container_id = Some(id.clone());
8724 ed.sheet = S::Stops;
8725 }
8726 self.state
8727 .push_log(format!("Route: rest bed set to {name}"));
8728 }
8729 }
8730 S::WaypointMapPick => {}
8731 }
8732 }
8733
8734 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
8741 use crate::worker_route_editor as wre;
8742 use wre::RouteEditorSheet as S;
8743 if self.state.worker_route_editor.is_none() {
8744 return;
8745 }
8746 let sheet = self
8747 .state
8748 .worker_route_editor
8749 .as_ref()
8750 .map(|ed| ed.sheet.clone())
8751 .unwrap_or(S::Stops);
8752 match sheet {
8753 S::WaypointMapPick => {
8754 let (_, _, z) = self.state.player_position_with_z();
8755 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
8756 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
8757 let editing = self
8759 .state
8760 .worker_route_editor
8761 .as_ref()
8762 .is_some_and(|ed| ed.editing_index.is_some());
8763 if !editing {
8764 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8765 ed.sheet = S::WaypointMapPick;
8766 }
8767 }
8768 }
8769 S::HarvestPicker { .. } => {
8770 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
8771 let stop = wre::WorkerRouteStop::HarvestNode {
8772 node_id: node.id.clone(),
8773 };
8774 let label = node.label.clone();
8775 self.re_confirm_stop(stop, format!("harvest {label}"));
8776 }
8777 }
8778 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
8779 if let Some(cid) = wre::pick_storage_container_at(
8781 &self.state.placed_containers,
8782 self.state.character_id,
8783 x,
8784 y,
8785 ) {
8786 self.re_open_withdraw_items(cid);
8787 }
8788 }
8789 S::DepositContainers { .. } | S::DepositFilter { .. } => {
8790 if let Some(cid) = wre::pick_storage_container_at(
8791 &self.state.placed_containers,
8792 self.state.character_id,
8793 x,
8794 y,
8795 ) {
8796 self.re_open_deposit_filter(cid);
8797 }
8798 }
8799 S::SellNpcs { .. } => {
8800 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8801 self.re_open_sell_item(Some(npc_id));
8802 }
8803 }
8804 S::SellItem { .. } => {
8805 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8806 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8807 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
8808 *slot = Some(npc_id.clone());
8809 }
8810 }
8811 self.state
8812 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
8813 }
8814 }
8815 _ => self.worker_route_editor_quick_add_click(x, y),
8817 }
8818 }
8819
8820 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
8824 use crate::worker_route_editor as wre;
8825 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
8826 let dx = ax - bx;
8827 let dy = ay - by;
8828 (dx * dx + dy * dy).sqrt()
8829 };
8830
8831 let selected_stop_kind = self
8834 .state
8835 .worker_route_editor
8836 .as_ref()
8837 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
8838 .map(|s| match s {
8839 wre::WorkerRouteStop::TradeWith { .. } => 1,
8840 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
8841 _ => 0,
8842 })
8843 .unwrap_or(0);
8844 if selected_stop_kind == 1 {
8845 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8846 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8847 ed.set_selected_trade_npc(npc_id.clone());
8848 }
8849 self.state
8850 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
8851 return;
8852 }
8853 }
8854 if selected_stop_kind == 2 {
8855 if let Some(cid) = wre::pick_storage_container_at(
8856 &self.state.placed_containers,
8857 self.state.character_id,
8858 x,
8859 y,
8860 ) {
8861 let name = self
8862 .state
8863 .placed_containers
8864 .iter()
8865 .find(|c| c.id == cid)
8866 .map(|c| c.display_name.clone())
8867 .unwrap_or_else(|| "container".into());
8868 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8869 ed.set_selected_withdraw_container(cid.clone());
8870 }
8871 self.state
8872 .push_log(format!("Route: withdraw source → {name}"));
8873 return;
8874 }
8875 }
8876
8877 enum Target {
8880 Bed(String),
8881 Container(String),
8882 Npc(String, String),
8883 Node(String, String),
8884 }
8885 let mut best: Option<(f32, u8, Target)> = None;
8886 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
8887 let better = match best {
8888 None => true,
8889 Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
8890 };
8891 if better {
8892 *best = Some((d, rank, t));
8893 }
8894 };
8895 if let Some(bed_id) = wre::pick_lodging_container_at(
8896 &self.state.placed_containers,
8897 self.state.character_id,
8898 x,
8899 y,
8900 ) {
8901 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
8902 let already_bed = self
8905 .state
8906 .worker_route_editor
8907 .as_ref()
8908 .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
8909 if already_bed {
8910 consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
8911 } else {
8912 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
8913 }
8914 }
8915 }
8916 if let Some(cid) = wre::pick_storage_container_at(
8917 &self.state.placed_containers,
8918 self.state.character_id,
8919 x,
8920 y,
8921 ) {
8922 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
8923 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
8924 }
8925 }
8926 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8927 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
8928 consider(
8929 dist(x, y, n.x, n.y),
8930 2,
8931 Target::Npc(npc_id, label),
8932 &mut best,
8933 );
8934 }
8935 }
8936 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
8937 let d = dist(x, y, node.x, node.y);
8938 consider(
8939 d,
8940 3,
8941 Target::Node(node.id.clone(), node.label.clone()),
8942 &mut best,
8943 );
8944 }
8945
8946 match best.map(|(_, _, t)| t) {
8947 Some(Target::Bed(bed_id)) => {
8948 let name = self
8949 .state
8950 .placed_containers
8951 .iter()
8952 .find(|c| c.id == bed_id)
8953 .map(|c| c.display_name.clone())
8954 .unwrap_or_else(|| "camp bed".into());
8955 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8956 ed.lodging_container_id = Some(bed_id.clone());
8957 }
8958 self.state
8959 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
8960 }
8961 Some(Target::Container(cid)) => {
8962 let name = self
8963 .state
8964 .placed_containers
8965 .iter()
8966 .find(|c| c.id == cid)
8967 .map(|c| c.display_name.clone())
8968 .unwrap_or_else(|| "container".into());
8969 let added = self
8970 .state
8971 .worker_route_editor
8972 .as_mut()
8973 .is_some_and(|ed| ed.append_deposit_at(&cid));
8974 if added {
8975 self.state
8976 .push_log(format!("Route: + deposit at {name} ({cid})"));
8977 } else {
8978 self.state.push_log(format!(
8979 "Route: {name} already in route — selected it (d to remove)"
8980 ));
8981 }
8982 }
8983 Some(Target::Npc(npc_id, label)) => {
8984 let template = self.re_template_candidates().into_iter().next();
8987 let Some(template) = template else {
8988 self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
8989 return;
8990 };
8991 let added = self
8992 .state
8993 .worker_route_editor
8994 .as_mut()
8995 .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
8996 if added {
8997 self.state
8998 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
8999 } else {
9000 self.state.push_log(format!(
9001 "Route: {label} already sells {template} — selected it (d to remove)"
9002 ));
9003 }
9004 }
9005 Some(Target::Node(id, label)) => {
9006 let added = self
9007 .state
9008 .worker_route_editor
9009 .as_mut()
9010 .is_some_and(|ed| ed.append_harvest_node(&id));
9011 if added {
9012 self.state
9013 .push_log(format!("Route: + harvest node {label} ({id})"));
9014 } else {
9015 self.state.push_log(format!(
9016 "Route: {label} already in route — selected it (d to remove)"
9017 ));
9018 }
9019 }
9020 None => {}
9021 }
9022 }
9023
9024 pub fn worker_route_editor_select(&mut self, delta: i32) {
9025 let Some(ed) = self.state.worker_route_editor.as_mut() else {
9026 return;
9027 };
9028 if ed.stops.is_empty() {
9029 return;
9030 }
9031 let n = ed.stops.len() as i32;
9032 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
9033 ed.selected_stop_index = next;
9034 }
9035
9036 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
9037 let Some(ed) = self.state.worker_route_editor.as_mut() else {
9038 return;
9039 };
9040 if delta < 0 {
9041 ed.move_selected_up();
9042 } else if delta > 0 {
9043 ed.move_selected_down();
9044 }
9045 }
9046
9047 pub fn worker_route_editor_delete_selected(&mut self) {
9048 let removed = self
9049 .state
9050 .worker_route_editor
9051 .as_mut()
9052 .is_some_and(|ed| {
9053 let before = ed.stop_count();
9054 ed.remove_selected_stop();
9055 ed.stop_count() < before
9056 });
9057 if removed {
9058 self.state.push_log("Route: removed selected stop");
9059 }
9060 }
9061
9062 pub fn worker_route_editor_clear_stops(&mut self) {
9065 let Some(ed) = self.state.worker_route_editor.as_mut() else {
9066 return;
9067 };
9068 if ed.stops.is_empty() {
9069 self.state.push_log("Route: already empty — s saves an idle worker".to_string());
9070 return;
9071 }
9072 ed.stops.clear();
9073 ed.selected_stop_index = 0;
9074 self.state
9075 .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
9076 }
9077
9078 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
9079 if self.state.pending_worker_job_ack.is_some() {
9080 anyhow::bail!("route save still pending — wait for server ack");
9081 }
9082 let Some(ed) = self.state.worker_route_editor.clone() else {
9083 anyhow::bail!("route editor not open");
9084 };
9085 let (job_yaml, idle) = if ed.stops.is_empty() {
9088 (ed.build_idle_job_yaml(), true)
9089 } else {
9090 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
9091 };
9092 let worker_id = ed.worker_instance_id.clone();
9093 let route_view = if idle {
9094 None
9095 } else {
9096 Some(ed.to_route_view())
9097 };
9098 let mode = if idle {
9099 flatland_protocol::WorkerModeView::Idle
9100 } else {
9101 flatland_protocol::WorkerModeView::JobLoop
9102 };
9103 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
9104 .state
9105 .hired_workers
9106 .iter()
9107 .find(|w| w.instance_id == worker_id)
9108 .map(|w| {
9109 (
9110 w.route.clone(),
9111 w.mode,
9112 w.step_label.clone(),
9113 w.last_error.clone(),
9114 )
9115 })
9116 .unwrap_or((
9117 None,
9118 flatland_protocol::WorkerModeView::Idle,
9119 String::new(),
9120 None,
9121 ));
9122 self.seq += 1;
9123 let seq = self.seq;
9124 self.session
9125 .submit_intent(Intent::SetWorkerJob {
9126 entity_id: self.state.entity_id,
9127 worker_instance_id: worker_id.clone(),
9128 job_yaml,
9129 seq,
9130 })
9131 .await?;
9132 self.state.intents_sent += 1;
9133 if let Some(w) = self
9134 .state
9135 .hired_workers
9136 .iter_mut()
9137 .find(|w| w.instance_id == worker_id)
9138 {
9139 w.route = route_view;
9140 w.mode = mode;
9141 w.last_error = None;
9142 if idle {
9143 w.step_label.clear();
9144 }
9145 }
9146 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
9147 seq,
9148 worker_instance_id: worker_id,
9149 worker_label: ed.worker_label.clone(),
9150 idle,
9151 stop_count: ed.stops.len(),
9152 prev_route,
9153 prev_mode,
9154 prev_step_label,
9155 prev_last_error,
9156 });
9157 self.state.push_log(format!(
9158 "Route: saving for {}… (waiting for server)",
9159 ed.worker_label
9160 ));
9161 Ok(())
9163 }
9164 pub fn quest_menu_move(&mut self, delta: i32) {
9165 let n = self.state.active_quest_entries().len();
9166 if n == 0 {
9167 return;
9168 }
9169 let idx = self.state.quest_menu_index as i32;
9170 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9171 }
9172
9173 pub fn quest_menu_page(&mut self, pages: i32) {
9174 let n = self.state.active_quest_entries().len();
9175 self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
9176 }
9177
9178 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
9179 let Some(offer) = self.state.pending_quest_offer.clone() else {
9180 anyhow::bail!("no quest offer");
9181 };
9182 self.seq += 1;
9183 let seq = self.seq;
9184 self.session
9185 .submit_intent(Intent::AcceptQuest {
9186 entity_id: self.state.entity_id,
9187 quest_id: offer.quest_id,
9188 seq,
9189 })
9190 .await?;
9191 self.state.intents_sent += 1;
9192 Ok(())
9193 }
9194
9195 pub fn quest_offer_decline(&mut self) {
9196 self.state.show_quest_offer = false;
9197 self.state.pending_quest_offer = None;
9198 if !self.state.show_npc_chat
9199 && !self.state.show_shop_menu
9200 && self.state.npc_verb_target.is_some()
9201 {
9202 self.state.show_npc_verb_menu = true;
9203 }
9204 }
9205
9206 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
9207 if !self.state.show_quest_menu {
9208 return Ok(());
9209 }
9210 let active: Vec<_> = self
9211 .state
9212 .active_quest_entries()
9213 .into_iter()
9214 .cloned()
9215 .collect();
9216 let Some(entry) = active.get(self.state.quest_menu_index) else {
9217 return Ok(());
9218 };
9219 if self.state.quest_withdraw_confirm {
9220 if !entry.can_withdraw {
9221 anyhow::bail!("quest cannot be withdrawn");
9222 }
9223 self.seq += 1;
9224 let seq = self.seq;
9225 self.session
9226 .submit_intent(Intent::WithdrawQuest {
9227 entity_id: self.state.entity_id,
9228 quest_id: entry.quest_id.clone(),
9229 seq,
9230 })
9231 .await?;
9232 self.state.intents_sent += 1;
9233 self.state.quest_withdraw_confirm = false;
9234 return Ok(());
9235 }
9236 self.seq += 1;
9237 let seq = self.seq;
9238 self.session
9239 .submit_intent(Intent::TrackQuest {
9240 entity_id: self.state.entity_id,
9241 quest_id: entry.quest_id.clone(),
9242 seq,
9243 })
9244 .await?;
9245 self.state.intents_sent += 1;
9246 Ok(())
9247 }
9248
9249 pub fn quest_request_withdraw(&mut self) {
9250 if self.state.show_quest_menu {
9251 self.state.quest_withdraw_confirm = true;
9252 }
9253 }
9254
9255 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
9256 if !self.state.is_alive() {
9257 anyhow::bail!("you are dead");
9258 }
9259 let Some(catalog) = self.state.shop_catalog.clone() else {
9260 anyhow::bail!("no shop open");
9261 };
9262 self.seq += 1;
9263 let seq = self.seq;
9264 match self.state.shop_tab {
9265 ShopTab::Buy => {
9266 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
9267 anyhow::bail!("nothing selected");
9268 };
9269 if offer.already_owned {
9270 anyhow::bail!("already owned");
9271 }
9272 self.session
9273 .submit_intent(Intent::ShopBuy {
9274 entity_id: self.state.entity_id,
9275 npc_id: catalog.npc_id.clone(),
9276 offer_id: offer.offer_id.clone(),
9277 quantity: self.state.shop_quantity,
9278 seq,
9279 })
9280 .await?;
9281 }
9282 ShopTab::Sell => {
9283 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
9284 anyhow::bail!("nothing to sell");
9285 };
9286 if line.quantity == 0 {
9287 anyhow::bail!("you have no {}", line.label);
9288 }
9289 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
9290 self.session
9291 .submit_intent(Intent::ShopSell {
9292 entity_id: self.state.entity_id,
9293 npc_id: catalog.npc_id.clone(),
9294 template_id: line.template_id.clone(),
9295 quantity,
9296 seq,
9297 })
9298 .await?;
9299 }
9300 }
9301 self.state.intents_sent += 1;
9302 Ok(())
9303 }
9304
9305 pub fn craft_menu_move(&mut self, delta: i32) {
9306 let n = self.state.blueprints.len();
9307 if n == 0 {
9308 return;
9309 }
9310 let idx = self.state.craft_menu_index as i32;
9311 let next = (idx + delta).rem_euclid(n as i32);
9312 self.state.craft_menu_index = next as usize;
9313 self.state.clamp_craft_batch_quantity();
9314 }
9315
9316 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
9317 self.state.craft_batch_adjust_quantity(delta);
9318 }
9319
9320 pub fn craft_batch_set_max(&mut self) {
9321 self.state.craft_batch_set_max();
9322 }
9323
9324 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
9325 let Some(blueprint) = self
9326 .state
9327 .blueprints
9328 .get(self.state.craft_menu_index)
9329 .cloned()
9330 else {
9331 anyhow::bail!("no blueprints known");
9332 };
9333 if !self.state.can_craft_blueprint(&blueprint) {
9334 let hint = self
9335 .state
9336 .craft_missing_hint(&blueprint)
9337 .unwrap_or_else(|| "missing materials".into());
9338 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
9339 }
9340 let count = self.state.craft_batch_quantity;
9341 let max = self.state.max_craft_batches(&blueprint);
9342 if max == 0 {
9343 anyhow::bail!("cannot craft {}", blueprint.label);
9344 }
9345 let batches = count.min(max);
9346 self.craft(&blueprint.id, Some(batches)).await?;
9347 self.state.show_craft_menu = false;
9348 Ok(())
9349 }
9350
9351 pub async fn move_by(
9352 &mut self,
9353 forward: f32,
9354 strafe: f32,
9355 vertical: f32,
9356 sprint: bool,
9357 ) -> anyhow::Result<()> {
9358 if !self.state.is_alive() {
9359 anyhow::bail!("you are dead");
9360 }
9361 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
9362 self.last_move_forward = forward;
9363 self.last_move_strafe = strafe;
9364 }
9365 self.seq += 1;
9366 self.session
9367 .submit_intent(Intent::Move {
9368 entity_id: self.state.entity_id,
9369 forward,
9370 strafe,
9371 vertical,
9372 sprint,
9373 seq: self.seq,
9374 })
9375 .await?;
9376 self.state.intents_sent += 1;
9377 Ok(())
9378 }
9379
9380 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
9381 if !self.state.connected {
9382 crate::harvest_trace!("harvest_nearest rejected: not connected");
9383 anyhow::bail!("not connected");
9384 }
9385 if !self.state.is_alive() {
9386 crate::harvest_trace!("harvest_nearest rejected: player dead");
9387 anyhow::bail!("you are dead");
9388 }
9389 if self.state.harvest_in_progress {
9390 if self.state.harvest_state_stale() {
9391 self.state.clear_harvest_state();
9392 } else {
9393 anyhow::bail!("already harvesting");
9394 }
9395 }
9396 let (px, py) = self
9397 .state
9398 .player
9399 .as_ref()
9400 .map(|p| (p.transform.position.x, p.transform.position.y))
9401 .unwrap_or((0.0, 0.0));
9402
9403 let available = self
9404 .state
9405 .resource_nodes
9406 .iter()
9407 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
9408 .count();
9409 let node_id = self
9410 .state
9411 .resource_nodes
9412 .iter()
9413 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
9414 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
9415 .min_by(|a, b| {
9416 let da = distance(px, py, a.x, a.y);
9417 let db = distance(px, py, b.x, b.y);
9418 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
9419 })
9420 .map(|n| n.id.clone());
9421
9422 let Some(node_id) = node_id else {
9423 let has_loot = self
9424 .state
9425 .ground_drops
9426 .iter()
9427 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
9428 if has_loot {
9429 return self.pickup_nearest().await;
9430 }
9431 anyhow::bail!(
9432 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
9433 );
9434 };
9435
9436 self.seq += 1;
9437 let seq = self.seq;
9438 crate::harvest_trace!(
9439 entity_id = self.state.entity_id,
9440 node_id = %node_id,
9441 seq,
9442 px,
9443 py,
9444 available_nodes = available,
9445 "submitting harvest intent"
9446 );
9447 self.session
9448 .submit_intent(Intent::Harvest {
9449 entity_id: self.state.entity_id,
9450 node_id,
9451 seq,
9452 })
9453 .await?;
9454 self.state.intents_sent += 1;
9455 self.state.harvest_in_progress = true;
9456 self.state.harvest_started_at = Some(Instant::now());
9457 self.state.push_log("Harvesting…");
9458 crate::harvest_trace!(
9459 entity_id = self.state.entity_id,
9460 seq,
9461 "harvest intent queued to session"
9462 );
9463 Ok(())
9464 }
9465
9466 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
9467 if !self.state.is_alive() {
9468 anyhow::bail!("you are dead");
9469 }
9470 let blueprint_id = self
9471 .state
9472 .blueprints
9473 .iter()
9474 .find(|bp| self.state.can_craft_blueprint(bp))
9475 .map(|bp| bp.id.clone())
9476 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
9477 self.craft(&blueprint_id, None).await
9478 }
9479
9480 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
9481 if !self.state.is_alive() {
9482 anyhow::bail!("you are dead");
9483 }
9484 self.seq += 1;
9485 self.session
9486 .submit_intent(Intent::Craft {
9487 entity_id: self.state.entity_id,
9488 blueprint_id: blueprint_id.to_string(),
9489 count,
9490 seq: self.seq,
9491 })
9492 .await?;
9493 self.state.intents_sent += 1;
9494 let (label, batches) = self
9495 .state
9496 .blueprints
9497 .iter()
9498 .find(|b| b.id == blueprint_id)
9499 .map(|b| {
9500 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
9501 (b.label.as_str(), n)
9502 })
9503 .unwrap_or((blueprint_id, count.unwrap_or(1)));
9504 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
9505 Ok(())
9506 }
9507
9508 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
9509 if !self.state.is_alive() {
9510 anyhow::bail!("you are dead");
9511 }
9512 let target_id = match self.state.nearest_interact_target() {
9513 Some(id) => id,
9514 None => {
9515 anyhow::bail!("nothing to interact with nearby");
9516 }
9517 };
9518 if self.state.npcs.iter().any(|n| n.id == target_id) {
9519 self.state.show_npc_verb_menu = true;
9520 self.state.npc_verb_target = Some(target_id);
9521 self.state.npc_verb_index = 0;
9522 return Ok(());
9523 }
9524 if let Ok(peer_id) = target_id.parse::<EntityId>() {
9525 if self
9526 .state
9527 .hired_workers
9528 .iter()
9529 .any(|w| w.entity_id == peer_id)
9530 {
9531 anyhow::bail!("use the workers menu for hired hands — not Whisper/Trade");
9532 }
9533 if let Some(entity) = self
9534 .state
9535 .entities
9536 .iter()
9537 .find(|e| e.id == peer_id && e.id != self.state.entity_id)
9538 {
9539 self.state
9540 .player_verbs
9541 .open_for(peer_id, &entity.label);
9542 return Ok(());
9543 }
9544 }
9545 self.seq += 1;
9546 self.session
9547 .submit_intent(Intent::Interact {
9548 entity_id: self.state.entity_id,
9549 target_id: target_id.clone(),
9550 seq: self.seq,
9551 })
9552 .await?;
9553 self.state.intents_sent += 1;
9554 Ok(())
9555 }
9556
9557 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
9559 if !self.state.is_alive() {
9560 anyhow::bail!("you are dead");
9561 }
9562 if self.state.nearest_interact_target().is_some() {
9563 return self.interact_nearest().await;
9564 }
9565 if let Some((label, dist)) = self.state.nearest_quest_board() {
9568 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
9569 anyhow::bail!(
9570 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
9571 );
9572 }
9573 }
9574 let (px, py) = self.state.player_position();
9575 let has_loot = self
9576 .state
9577 .ground_drops
9578 .iter()
9579 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
9580 if has_loot {
9581 return self.pickup_nearest().await;
9582 }
9583 if self
9584 .state
9585 .placed_containers
9586 .iter()
9587 .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
9588 {
9589 return self.pickup_nearest_container().await;
9590 }
9591 match self.harvest_nearest().await {
9592 Ok(()) => Ok(()),
9593 Err(err) => {
9594 let msg = err.to_string();
9595 if msg.contains("no harvestable")
9596 || msg.contains("press p")
9597 || msg.contains("press f")
9598 {
9599 anyhow::bail!(
9600 "nothing to use nearby — stand by an NPC/door, loot (*), chest, or resource"
9601 );
9602 }
9603 Err(err)
9604 }
9605 }
9606 }
9607
9608 pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
9611 if !self.state.is_alive() {
9612 anyhow::bail!("you are dead");
9613 }
9614 let binding = self
9615 .state
9616 .hotbar_ability(slot)
9617 .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
9618 .to_string();
9619 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
9620 let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
9621 if qty == 0 {
9622 anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
9623 }
9624 return self.use_item(template_id).await;
9625 }
9626 let ability_id = binding;
9627 let target = if ability_id == "heal_touch" {
9628 Some(
9629 self.state
9630 .target_for_slot(2)
9631 .unwrap_or(self.state.entity_id),
9632 )
9633 } else {
9634 self.state
9635 .target_for_slot(1)
9636 .or_else(|| self.state.target_for_slot(2))
9637 };
9638 let Some(target_id) = target else {
9639 anyhow::bail!("no target — Tab to select, then press the hotbar key");
9640 };
9641 self.cast_ability(&ability_id, Some(target_id)).await
9642 }
9643
9644 pub async fn set_hotbar_slot(
9647 &mut self,
9648 slot: u8,
9649 ability_id: Option<&str>,
9650 ) -> anyhow::Result<()> {
9651 if !self.state.is_alive() {
9652 anyhow::bail!("you are dead");
9653 }
9654 if !(1..=9).contains(&slot) {
9655 anyhow::bail!("hotbar slot must be 1–9");
9656 }
9657 let ability_id = ability_id
9658 .map(str::trim)
9659 .filter(|id| !id.is_empty())
9660 .map(str::to_string);
9661 self.seq += 1;
9662 self.session
9663 .submit_intent(Intent::SetHotbarSlot {
9664 entity_id: self.state.entity_id,
9665 slot,
9666 ability_id: ability_id.clone(),
9667 seq: self.seq,
9668 })
9669 .await?;
9670 self.state.intents_sent += 1;
9671 let idx = (slot - 1) as usize;
9672 if self.state.hotbar.len() < 9 {
9673 self.state.hotbar.resize(9, None);
9674 }
9675 if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
9676 *slot_mut = ability_id.clone();
9677 }
9678 match ability_id {
9679 Some(id) => {
9680 let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
9681 format!("use {tid}")
9682 } else {
9683 id
9684 };
9685 self.state.push_log(format!("Hotbar {slot} → {label}"))
9686 }
9687 None => self.state.push_log(format!("Hotbar {slot} cleared")),
9688 }
9689 Ok(())
9690 }
9691
9692 pub fn npc_verb_options(&self) -> Vec<&'static str> {
9693 self.state.npc_verb_options()
9694 }
9695
9696 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
9697 let Some(npc_id) = self.state.npc_verb_target.clone() else {
9698 return Ok(());
9699 };
9700 let options = self.npc_verb_options();
9701 let choice = options
9702 .get(self.state.npc_verb_index)
9703 .copied()
9704 .unwrap_or("Talk");
9705 self.seq += 1;
9706 match choice {
9707 "Trade" | "Bank" | "Storage" => {
9708 self.session
9709 .submit_intent(Intent::Interact {
9710 entity_id: self.state.entity_id,
9711 target_id: npc_id,
9712 seq: self.seq,
9713 })
9714 .await?;
9715 }
9716 _ => {
9717 self.session
9718 .submit_intent(Intent::NpcTalkOpen {
9719 entity_id: self.state.entity_id,
9720 npc_id,
9721 seq: self.seq,
9722 })
9723 .await?;
9724 }
9725 }
9726 self.state.intents_sent += 1;
9727 Ok(())
9728 }
9729
9730 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
9731 let Some(chat) = self.state.npc_chat.clone() else {
9732 return Ok(());
9733 };
9734 let message = chat.input.trim().to_string();
9735 if message.is_empty() || chat.pending {
9736 return Ok(());
9737 }
9738 if let Some(c) = self.state.npc_chat.as_mut() {
9739 c.lines.push(format!("You: {message}"));
9740 c.input.clear();
9741 c.pending = true;
9742 }
9743 self.seq += 1;
9744 self.session
9745 .submit_intent(Intent::NpcTalkSay {
9746 entity_id: self.state.entity_id,
9747 npc_id: chat.npc_id,
9748 message,
9749 seq: self.seq,
9750 })
9751 .await?;
9752 self.state.intents_sent += 1;
9753 Ok(())
9754 }
9755
9756 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
9757 let return_to_verbs = self.state.npc_verb_target.is_some();
9758 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
9759 self.state.show_npc_chat = false;
9760 if return_to_verbs {
9761 self.state.show_npc_verb_menu = true;
9762 }
9763 return Ok(());
9764 };
9765 self.seq += 1;
9766 self.session
9767 .submit_intent(Intent::NpcTalkClose {
9768 entity_id: self.state.entity_id,
9769 npc_id,
9770 seq: self.seq,
9771 })
9772 .await?;
9773 self.state.intents_sent += 1;
9774 self.state.show_npc_chat = false;
9775 self.state.npc_chat = None;
9776 if return_to_verbs {
9777 self.state.show_npc_verb_menu = true;
9778 }
9779 Ok(())
9780 }
9781
9782 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
9784 if self.state.show_quest_offer
9785 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
9786 {
9787 self.quest_offer_decline();
9788 return Ok(());
9789 }
9790 if self.state.show_npc_chat {
9791 return self.npc_talk_close().await;
9792 }
9793 if self.state.show_shop_menu {
9794 return self.back_from_shop_menu().await;
9795 }
9796 if self.state.bank_panel.is_some() {
9797 if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
9798 self.bank_transfer_back();
9799 return Ok(());
9800 }
9801 return self.close_bank_panel().await;
9802 }
9803 if self.state.storage_panel.is_some() {
9804 if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
9805 self.storage_ui_back();
9806 return Ok(());
9807 }
9808 return self.close_storage_panel().await;
9809 }
9810 if self.state.show_npc_verb_menu {
9811 self.state.show_npc_verb_menu = false;
9812 self.state.npc_verb_target = None;
9813 }
9814 Ok(())
9815 }
9816
9817 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
9818 self.seq += 1;
9819 self.session
9820 .submit_intent(Intent::TestDamage {
9821 entity_id: self.state.entity_id,
9822 amount,
9823 seq: self.seq,
9824 })
9825 .await?;
9826 self.state.intents_sent += 1;
9827 Ok(())
9828 }
9829
9830 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
9831 self.cycle_combat_target_slot(1, reverse).await
9832 }
9833
9834 pub async fn cycle_combat_target_slot(
9835 &mut self,
9836 slot_index: u8,
9837 reverse: bool,
9838 ) -> anyhow::Result<()> {
9839 if !self.state.is_alive() {
9840 anyhow::bail!("you are dead");
9841 }
9842 let candidates = self.state.candidates_for_slot(slot_index);
9843 if candidates.is_empty() {
9844 anyhow::bail!("no targets nearby");
9845 }
9846 let current = self.state.target_for_slot(slot_index);
9847 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
9848 let next_idx = match idx {
9849 None => 0,
9850 Some(i) if reverse => {
9851 if i == 0 {
9852 candidates.len() - 1
9853 } else {
9854 i - 1
9855 }
9856 }
9857 Some(i) => (i + 1) % candidates.len(),
9858 };
9859 if idx == Some(next_idx) && candidates.len() == 1 {
9860 self.clear_combat_target_slot(slot_index).await?;
9861 return Ok(());
9862 }
9863 let (target_id, label) = candidates[next_idx].clone();
9864 self.set_combat_target_slot(slot_index, target_id, &label)
9865 .await
9866 }
9867
9868 pub async fn set_combat_target_slot(
9869 &mut self,
9870 slot_index: u8,
9871 target_id: EntityId,
9872 label: &str,
9873 ) -> anyhow::Result<()> {
9874 if !self.state.is_alive() {
9875 anyhow::bail!("you are dead");
9876 }
9877 self.seq += 1;
9878 self.session
9879 .submit_intent(Intent::SetTargetSlot {
9880 entity_id: self.state.entity_id,
9881 slot_index,
9882 target_id,
9883 seq: self.seq,
9884 })
9885 .await?;
9886 self.state.intents_sent += 1;
9887 if slot_index == 1 {
9888 self.state.combat_target = Some(target_id);
9889 self.state.combat_target_label = Some(label.to_string());
9890 }
9891 self.state
9892 .push_log(format!("Slot {slot_index} target: {label}"));
9893 Ok(())
9894 }
9895
9896 pub async fn set_combat_target(
9897 &mut self,
9898 target_id: EntityId,
9899 label: &str,
9900 ) -> anyhow::Result<()> {
9901 self.set_combat_target_slot(1, target_id, label).await
9902 }
9903
9904 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
9905 if slot_index == 1 && self.state.combat_target.is_none() {
9906 return Ok(());
9907 }
9908 self.seq += 1;
9909 self.session
9910 .submit_intent(Intent::ClearTargetSlot {
9911 entity_id: self.state.entity_id,
9912 slot_index,
9913 seq: self.seq,
9914 })
9915 .await?;
9916 if slot_index == 1 {
9917 self.state.combat_target = None;
9918 self.state.combat_target_label = None;
9919 }
9920 self.state.intents_sent += 1;
9921 self.state
9922 .push_log(format!("Slot {slot_index} target cleared"));
9923 Ok(())
9924 }
9925
9926 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
9927 self.clear_combat_target_slot(1).await
9928 }
9929
9930 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
9931 if !self.state.is_alive() {
9932 anyhow::bail!("you are dead");
9933 }
9934 self.seq += 1;
9935 self.session
9936 .submit_intent(Intent::AdvanceRotation {
9937 entity_id: self.state.entity_id,
9938 slot_index,
9939 seq: self.seq,
9940 })
9941 .await?;
9942 self.state.intents_sent += 1;
9943 Ok(())
9944 }
9945
9946 pub async fn assign_slot_preset(
9947 &mut self,
9948 slot_index: u8,
9949 preset_id: &str,
9950 ) -> anyhow::Result<()> {
9951 if !self.state.is_alive() {
9952 anyhow::bail!("you are dead");
9953 }
9954 self.seq += 1;
9955 self.session
9956 .submit_intent(Intent::AssignSlotPreset {
9957 entity_id: self.state.entity_id,
9958 slot_index,
9959 preset_id: preset_id.to_string(),
9960 seq: self.seq,
9961 })
9962 .await?;
9963 self.state.intents_sent += 1;
9964 if let Some(slot) = self
9965 .state
9966 .combat_slots
9967 .iter_mut()
9968 .find(|s| s.slot_index == slot_index)
9969 {
9970 slot.preset_id = Some(preset_id.to_string());
9971 if let Some(preset) = self
9972 .state
9973 .rotation_presets
9974 .iter()
9975 .find(|p| p.id == preset_id)
9976 {
9977 slot.preset_label = Some(preset.label.clone());
9978 slot.rotation = preset.abilities.clone();
9979 slot.rotation_index = 0;
9980 }
9981 }
9982 self.state
9983 .push_log(format!("T{slot_index} loadout → {preset_id}"));
9984 Ok(())
9985 }
9986
9987 pub async fn cast_ability(
9988 &mut self,
9989 ability_id: &str,
9990 target_id: Option<EntityId>,
9991 ) -> anyhow::Result<()> {
9992 if !self.state.is_alive() {
9993 anyhow::bail!("you are dead");
9994 }
9995 let target_id = target_id
9996 .or_else(|| self.state.target_for_slot(2))
9997 .or_else(|| self.state.target_for_slot(1))
9998 .unwrap_or(self.state.entity_id);
9999 self.seq += 1;
10000 self.session
10001 .submit_intent(Intent::Cast {
10002 entity_id: self.state.entity_id,
10003 ability_id: ability_id.to_string(),
10004 target_id,
10005 seq: self.seq,
10006 })
10007 .await?;
10008 self.state.intents_sent += 1;
10009 self.state
10010 .push_log(format!("Cast {ability_id} → {target_id}"));
10011 Ok(())
10012 }
10013
10014 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
10015 self.seq += 1;
10016 self.session
10017 .submit_intent(Intent::UpsertRotationPreset {
10018 entity_id: self.state.entity_id,
10019 preset: preset.clone(),
10020 seq: self.seq,
10021 })
10022 .await?;
10023 self.state.intents_sent += 1;
10024 if let Some(existing) = self
10025 .state
10026 .rotation_presets
10027 .iter_mut()
10028 .find(|p| p.id == preset.id)
10029 {
10030 *existing = preset.clone();
10031 } else {
10032 self.state.rotation_presets.push(preset.clone());
10033 }
10034 for slot in &mut self.state.combat_slots {
10035 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
10036 slot.preset_label = Some(preset.label.clone());
10037 slot.rotation = preset.abilities.clone();
10038 }
10039 }
10040 self.state
10041 .push_log(format!("Saved rotation: {}", preset.label));
10042 Ok(())
10043 }
10044
10045 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
10046 self.seq += 1;
10047 self.session
10048 .submit_intent(Intent::DeleteRotationPreset {
10049 entity_id: self.state.entity_id,
10050 preset_id: preset_id.to_string(),
10051 seq: self.seq,
10052 })
10053 .await?;
10054 self.state.intents_sent += 1;
10055 self.state.rotation_presets.retain(|p| p.id != preset_id);
10056 for slot in &mut self.state.combat_slots {
10057 if slot.preset_id.as_deref() == Some(preset_id) {
10058 slot.preset_id = None;
10059 slot.preset_label = None;
10060 slot.rotation.clear();
10061 slot.rotation_index = 0;
10062 }
10063 }
10064 self.state
10065 .push_log(format!("Deleted rotation: {preset_id}"));
10066 Ok(())
10067 }
10068
10069 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
10070 if !self.state.is_alive() {
10071 anyhow::bail!("you are dead");
10072 }
10073 let enabled = !self
10074 .state
10075 .combat_slots
10076 .iter()
10077 .find(|s| s.slot_index == slot_index)
10078 .map(|s| s.auto_enabled)
10079 .unwrap_or(false);
10080 self.seq += 1;
10081 self.session
10082 .submit_intent(Intent::SetAutoAttack {
10083 entity_id: self.state.entity_id,
10084 slot_index,
10085 enabled,
10086 seq: self.seq,
10087 })
10088 .await?;
10089 if slot_index == 1 {
10090 self.state.auto_attack = enabled;
10091 }
10092 self.state.intents_sent += 1;
10093 self.state.push_log(format!(
10094 "T{slot_index} auto {}",
10095 if enabled { "ON" } else { "OFF" }
10096 ));
10097 Ok(())
10098 }
10099
10100 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
10101 if !self.state.connected {
10102 anyhow::bail!("not connected");
10103 }
10104 if !self.state.is_alive() {
10105 anyhow::bail!("you are dead");
10106 }
10107 let (px, py) = self.state.player_position();
10108 if self
10109 .state
10110 .ground_drops
10111 .iter()
10112 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
10113 {
10114 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
10115 }
10116 self.seq += 1;
10117 self.session
10118 .submit_intent(Intent::Pickup {
10119 entity_id: self.state.entity_id,
10120 drop_id: None,
10121 seq: self.seq,
10122 })
10123 .await?;
10124 self.state.intents_sent += 1;
10125 Ok(())
10126 }
10127
10128 pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
10129 self.toggle_auto_attack_slot(1).await
10130 }
10131
10132 pub async fn dodge(&mut self) -> anyhow::Result<()> {
10133 if !self.state.is_alive() {
10134 anyhow::bail!("you are dead");
10135 }
10136 self.seq += 1;
10137 self.session
10138 .submit_intent(Intent::Dodge {
10139 entity_id: self.state.entity_id,
10140 seq: self.seq,
10141 })
10142 .await?;
10143 self.state.intents_sent += 1;
10144 self.state.push_log("Dodge!");
10145 Ok(())
10146 }
10147
10148 pub async fn lunge(&mut self) -> anyhow::Result<()> {
10149 if !self.state.is_alive() {
10150 anyhow::bail!("you are dead");
10151 }
10152 let (forward, strafe) = self.last_move_axes();
10153 self.seq += 1;
10154 self.session
10155 .submit_intent(Intent::Lunge {
10156 entity_id: self.state.entity_id,
10157 forward,
10158 strafe,
10159 seq: self.seq,
10160 })
10161 .await?;
10162 self.state.intents_sent += 1;
10163 self.state.push_log("Lunge!");
10164 Ok(())
10165 }
10166
10167 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
10168 if !self.state.is_alive() {
10169 anyhow::bail!("you are dead");
10170 }
10171 self.seq += 1;
10172 self.session
10173 .submit_intent(Intent::DirectionalJump {
10174 entity_id: self.state.entity_id,
10175 forward,
10176 strafe,
10177 seq: self.seq,
10178 })
10179 .await?;
10180 self.state.intents_sent += 1;
10181 self.state.push_log("Jump!");
10182 Ok(())
10183 }
10184
10185 pub fn last_move_axes(&self) -> (f32, f32) {
10187 (self.last_move_forward, self.last_move_strafe)
10188 }
10189
10190 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
10191 if !self.state.is_alive() {
10192 anyhow::bail!("you are dead");
10193 }
10194 self.seq += 1;
10195 self.session
10196 .submit_intent(Intent::Block {
10197 entity_id: self.state.entity_id,
10198 enabled,
10199 seq: self.seq,
10200 })
10201 .await?;
10202 self.state.intents_sent += 1;
10203 if enabled {
10204 self.state.push_log("Blocking");
10205 }
10206 Ok(())
10207 }
10208
10209 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
10210 if !self.state.is_alive() {
10211 anyhow::bail!("you are dead");
10212 }
10213 self.seq += 1;
10214 self.session
10215 .submit_intent(Intent::EquipMainhand {
10216 entity_id: self.state.entity_id,
10217 template_id,
10218 instance_id: None,
10219 seq: self.seq,
10220 })
10221 .await?;
10222 self.state.intents_sent += 1;
10223 Ok(())
10224 }
10225
10226 pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
10228 let idx = self.state.equip_menu_index;
10229 let slots = equip_paperdoll_rows(&self.state);
10230 let Some(row) = slots.get(idx) else {
10231 return Ok(());
10232 };
10233 match row {
10234 EquipPaperdollRow::Body { slot, filled } => {
10235 if *filled {
10236 self.equip_worn(*slot, None).await
10237 } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
10238 self.equip_worn(*slot, Some(inst)).await
10239 } else {
10240 self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
10241 Ok(())
10242 }
10243 }
10244 EquipPaperdollRow::Mainhand { filled } => {
10245 if *filled {
10246 self.unequip_mainhand().await
10247 } else if let Some(tid) = first_inventory_weapon(&self.state) {
10248 self.equip_mainhand(Some(tid)).await
10249 } else {
10250 self.state.push_log("No weapon in inventory".to_string());
10251 Ok(())
10252 }
10253 }
10254 EquipPaperdollRow::Offhand { filled, locked } => {
10255 if *locked {
10256 self.state
10257 .push_log("Offhand locked — two-handed weapon equipped".to_string());
10258 Ok(())
10259 } else if *filled {
10260 self.unequip_offhand().await
10261 } else if let Some(tid) = first_inventory_offhand(&self.state) {
10262 self.equip_offhand(Some(tid)).await
10263 } else {
10264 self.state
10265 .push_log("No offhand item in inventory".to_string());
10266 Ok(())
10267 }
10268 }
10269 }
10270 }
10271
10272 pub async fn say(
10273 &mut self,
10274 channel: flatland_protocol::ChatChannel,
10275 text: &str,
10276 ) -> anyhow::Result<()> {
10277 self.say_to(channel, text, None).await
10278 }
10279
10280 pub async fn say_to(
10281 &mut self,
10282 channel: flatland_protocol::ChatChannel,
10283 text: &str,
10284 to_entity: Option<EntityId>,
10285 ) -> anyhow::Result<()> {
10286 self.seq += 1;
10287 self.session
10288 .submit_intent(Intent::Say {
10289 entity_id: self.state.entity_id,
10290 channel,
10291 text: text.to_string(),
10292 to_entity,
10293 seq: self.seq,
10294 })
10295 .await?;
10296 self.state.intents_sent += 1;
10297 Ok(())
10298 }
10299
10300 pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
10301 let Some(peer) = self.state.player_verbs.target_entity else {
10302 return Ok(());
10303 };
10304 let label = self.state.player_verbs.target_label.clone();
10305 let choice = crate::social::PlayerVerbState::options()
10306 .get(self.state.player_verbs.index)
10307 .copied()
10308 .unwrap_or("Whisper");
10309 self.state.player_verbs.close();
10310 match choice {
10311 "Trade" => {
10312 self.seq += 1;
10315 self.session
10316 .submit_intent(Intent::TradeRequest {
10317 entity_id: self.state.entity_id,
10318 peer_entity_id: peer,
10319 seq: self.seq,
10320 })
10321 .await?;
10322 self.state.intents_sent += 1;
10323 self.state
10324 .social_chat
10325 .push_system(format!("Trade request sent to {label} — waiting for accept"));
10326 }
10327 "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
10328 _ => self.state.social_chat.focus_nearby(),
10329 }
10330 Ok(())
10331 }
10332
10333 pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
10334 let Some(pending) = self.state.social_chat.pending_trade.take() else {
10335 return Ok(());
10336 };
10337 self.seq += 1;
10338 self.session
10339 .submit_intent(Intent::TradeRespond {
10340 entity_id: self.state.entity_id,
10341 peer_entity_id: pending.from_entity,
10342 accept,
10343 seq: self.seq,
10344 })
10345 .await?;
10346 self.state.intents_sent += 1;
10347 if accept {
10348 self.state
10349 .social_chat
10350 .push_system(format!("Accepted trade with {}", pending.from_name));
10351 } else {
10352 self.state
10353 .social_chat
10354 .push_system(format!("Declined trade with {}", pending.from_name));
10355 }
10356 Ok(())
10357 }
10358
10359 pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
10360 let text = self.state.social_chat.buffer.trim().to_string();
10361 if text.is_empty() {
10362 return Ok(());
10363 }
10364 let thread = self.state.social_chat.thread;
10365 let channel = thread.channel();
10366 let to = thread.to_entity();
10367 self.state.social_chat.buffer.clear();
10368 self.say_to(channel, &text, to).await
10369 }
10370
10371 pub async fn trade_present_selected(
10372 &mut self,
10373 item_instance_id: uuid::Uuid,
10374 ) -> anyhow::Result<()> {
10375 self.trade_present_quantity(item_instance_id, None).await
10376 }
10377
10378 pub async fn trade_present_quantity(
10379 &mut self,
10380 item_instance_id: uuid::Uuid,
10381 quantity: Option<u32>,
10382 ) -> anyhow::Result<()> {
10383 self.seq += 1;
10384 self.session
10385 .submit_intent(Intent::TradePresent {
10386 entity_id: self.state.entity_id,
10387 item_instance_id,
10388 quantity,
10389 seq: self.seq,
10390 })
10391 .await?;
10392 self.state.intents_sent += 1;
10393 self.state.trade_ui.qty_entry = None;
10394 self.state.trade_ui.picking_inventory = false;
10395 Ok(())
10396 }
10397
10398 pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
10400 if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
10401 let qty = self.state.trade_ui.present_quantity();
10402 return self
10403 .trade_present_quantity(entry.item_instance_id, qty)
10404 .await;
10405 }
10406 if !self.state.trade_ui.picking_inventory {
10407 return Ok(());
10408 }
10409 let Some(stack) = self
10410 .state
10411 .inventory_stacks
10412 .get(self.state.trade_ui.inventory_index)
10413 .cloned()
10414 else {
10415 return Ok(());
10416 };
10417 let Some(id) = stack.item_instance_id else {
10418 return Ok(());
10419 };
10420 let label = stack
10421 .display_name
10422 .clone()
10423 .unwrap_or_else(|| stack.template_id.clone());
10424 if stack.quantity <= 1 {
10425 self.trade_present_quantity(id, Some(1)).await
10426 } else {
10427 self.state
10428 .trade_ui
10429 .begin_qty_entry(id, label, stack.quantity);
10430 Ok(())
10431 }
10432 }
10433
10434 pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
10435 self.seq += 1;
10436 self.session
10437 .submit_intent(Intent::TradeSetReady {
10438 entity_id: self.state.entity_id,
10439 ready,
10440 seq: self.seq,
10441 })
10442 .await?;
10443 self.state.intents_sent += 1;
10444 Ok(())
10445 }
10446
10447 pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
10448 self.seq += 1;
10449 self.session
10450 .submit_intent(Intent::TradeCancel {
10451 entity_id: self.state.entity_id,
10452 seq: self.seq,
10453 })
10454 .await?;
10455 self.state.intents_sent += 1;
10456 self.state.trade_ui.close();
10457 Ok(())
10458 }
10459
10460 pub async fn destroy_whisper_stone(
10461 &mut self,
10462 item_instance_id: uuid::Uuid,
10463 ) -> anyhow::Result<()> {
10464 self.seq += 1;
10465 self.session
10466 .submit_intent(Intent::DestroyWhisperStone {
10467 entity_id: self.state.entity_id,
10468 item_instance_id,
10469 seq: self.seq,
10470 })
10471 .await?;
10472 self.state.intents_sent += 1;
10473 Ok(())
10474 }
10475
10476 pub async fn stop(&mut self) -> anyhow::Result<()> {
10477 self.seq += 1;
10478 self.session
10479 .submit_intent(Intent::Stop {
10480 entity_id: self.state.entity_id,
10481 seq: self.seq,
10482 })
10483 .await?;
10484 self.state.intents_sent += 1;
10485 Ok(())
10486 }
10487
10488 pub fn disconnect(&self) {
10489 self.session.disconnect();
10490 }
10491}
10492
10493fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
10494 let dx = ax - bx;
10495 let dy = ay - by;
10496 (dx * dx + dy * dy).sqrt()
10497}
10498
10499#[cfg(test)]
10500mod tests {
10501 use std::collections::BTreeMap;
10502
10503 use super::*;
10504 use flatland_protocol::{
10505 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
10506 };
10507
10508 fn sample_state() -> GameState {
10509 let mut state = GameState {
10510 session_id: 1,
10511 entity_id: 1,
10512 character_id: None,
10513 tick: 0,
10514 chunk_rev: 0,
10515 content_rev: 0,
10516 publish_rev: 0,
10517 entities: vec![EntityState {
10518 id: 1,
10519 label: "You".into(),
10520 transform: Transform {
10521 position: WorldCoord::surface(128.0, 128.0),
10522 yaw: 0.0,
10523 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
10524 },
10525 vitals: None,
10526 attributes: None,
10527 skills: None,
10528 inside_building: None,
10529 tile_id: None,
10530 paperdoll_ref: None,
10531 presentation_state: None,
10532 sprite_mode: None,
10533 progression_xp: None,
10534 }],
10535 player: None,
10536 resource_nodes: vec![ResourceNodeView {
10537 id: "oak-1".into(),
10538 label: "Oak".into(),
10539 x: 130.0,
10540 y: 128.0,
10541 z: 0.0,
10542 item_template: "oak_log".into(),
10543 state: ResourceNodeState::Available,
10544 blocking: true,
10545 blocking_radius_m: 0.8,
10546 tile_id: None,
10547 paperdoll_ref: None,
10548 yaw: 0.0,
10549 pitch: 0.0,
10550 roll: 0.0,
10551 draw_scale: 1.0,
10552 sprite_mode: None,
10553 presentation_state: None,
10554 }],
10555 ground_drops: vec![],
10556 placed_containers: vec![],
10557 buildings: vec![BuildingView {
10558 id: "broker-hut".into(),
10559 label: "Broker".into(),
10560 x: 148.0,
10561 y: 118.0,
10562 width_m: 8.0,
10563 depth_m: 6.0,
10564 interior_blueprint: Some("broker_hut".into()),
10565 tags: vec![],
10566 }],
10567 doors: vec![flatland_protocol::DoorView {
10568 id: "door-1".into(),
10569 building_id: "broker-hut".into(),
10570 x: 148.0,
10571 y: 118.0,
10572 open: false,
10573 portal: Some("front".into()),
10574 }],
10575 interior_map: None,
10576 npcs: vec![],
10577 blueprints: vec![],
10578 world_width_m: 256.0,
10579 world_height_m: 256.0,
10580 terrain_zones: Vec::new(),
10581 z_platforms: Vec::new(),
10582 z_transitions: Vec::new(),
10583 world_clock: flatland_protocol::WorldClock::default(),
10584 inventory: std::collections::HashMap::new(),
10585 inventory_hints: std::collections::HashMap::new(),
10586 logs: VecDeque::new(),
10587 intents_sent: 0,
10588 ticks_received: 0,
10589 connected: true,
10590 disconnect_reason: None,
10591 show_stats: false,
10592 hud_log_hidden: false,
10593 show_equip_menu: false,
10594 equip_menu_index: 0,
10595 show_craft_menu: false,
10596 craft_menu_index: 0,
10597 craft_batch_quantity: 1,
10598 show_shop_menu: false,
10599 shop_catalog: None,
10600 bank_panel: None,
10601 bank_menu_index: 0,
10602 bank_ui_mode: BankUiMode::Menu,
10603 storage_panel: None,
10604 storage_menu_index: 0,
10605 storage_ui_mode: StorageUiMode::Menu,
10606 shop_tab: ShopTab::default(),
10607 shop_menu_index: 0,
10608 shop_quantity: 1,
10609 shop_trade_log: VecDeque::new(),
10610 show_npc_verb_menu: false,
10611 npc_verb_target: None,
10612 npc_verb_index: 0,
10613 player_verbs: crate::social::PlayerVerbState::default(),
10614 social_chat: crate::social::SocialChatState::default(),
10615 trade_ui: crate::social::TradeUiState::default(),
10616 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
10617 show_npc_chat: false,
10618 npc_chat: None,
10619 show_inventory_menu: false,
10620 inventory_menu_index: 0,
10621 inventory_tab: InventoryTab::OnPerson,
10622 inventory_filter: String::new(),
10623 inventory_filter_focused: false,
10624 show_move_picker: false,
10625 show_rename_prompt: false,
10626 show_worker_rename: false,
10627 rename_buffer: String::new(),
10628 move_picker_index: 0,
10629 move_picker: None,
10630 show_grant_picker: false,
10631 grant_picker_index: 0,
10632 grant_picker: None,
10633 show_destroy_picker: false,
10634 destroy_confirm_pending: false,
10635 destroy_picker: None,
10636 combat_target: None,
10637 combat_target_label: None,
10638 combat_fx: Vec::new(),
10639 in_combat: false,
10640 auto_attack: true,
10641 combat_has_los: false,
10642 attack_cd_ticks: 0,
10643 gcd_ticks: 0,
10644 weapon_ability_id: "unarmed".into(),
10645 mainhand_template_id: None,
10646 mainhand_label: None,
10647 offhand_template_id: None,
10648 offhand_label: None,
10649 mainhand_hand_slots: 1,
10650 defense: None,
10651 worn: BTreeMap::new(),
10652 carry_mass: 0.0,
10653 carry_mass_max: 0.0,
10654 encumbrance: flatland_protocol::EncumbranceState::Light,
10655 inventory_stacks: Vec::new(),
10656 keychain_stacks: Vec::new(),
10657 whisper_pouch_stacks: Vec::new(),
10658 combat_target_detail: None,
10659 statuses: Vec::new(),
10660 cast_progress: None,
10661 ability_cooldowns: Vec::new(),
10662 blocking_active: false,
10663 max_target_slots: 1,
10664 combat_slots: Vec::new(),
10665 rotation_presets: Vec::new(),
10666 known_abilities: Vec::new(),
10667 hotbar: vec![None; 9],
10668 max_abilities_per_rotation: 0,
10669 show_loadout_menu: false,
10670 show_keychain_menu: false,
10671 keychain_menu_index: 0,
10672 show_rotation_editor: false,
10673 loadout_menu_index: 0,
10674 loadout_hotbar_slot: 1,
10675 loadout_ability_index: 0,
10676 loadout_focus_presets: false,
10677 rotation_editor: RotationEditorState::default(),
10678 harvest_in_progress: false,
10679 harvest_started_at: None,
10680 pending_craft_ack: None,
10681 pending_worker_job_ack: None,
10682 quest_log: Vec::new(),
10683 interactables: Vec::new(),
10684 ledger: None,
10685 career: None,
10686 character_sheet_tab: CharacterSheetTab::Character,
10687 ledger_period: LedgerPeriod::Day,
10688 show_quest_offer: false,
10689 pending_quest_offer: None,
10690 show_quest_menu: false,
10691 quest_menu_index: 0,
10692 quest_withdraw_confirm: false,
10693 hired_workers: Vec::new(),
10694 show_workers_menu: false,
10695 workers_menu_index: 0,
10696 workers_menu_compact: false,
10697 worker_step_display: BTreeMap::new(),
10698 worker_error_display: BTreeMap::new(),
10699 show_worker_give_picker: false,
10700 worker_give_picker_index: 0,
10701 worker_give_picker: None,
10702 show_worker_give_target_picker: false,
10703 worker_give_target_picker_index: 0,
10704 worker_give_target_picker: None,
10705 show_worker_take_picker: false,
10706 worker_take_picker_index: 0,
10707 worker_take_picker: None,
10708 show_worker_teach_picker: false,
10709 worker_teach_picker_index: 0,
10710 worker_teach_picker: None,
10711 worker_route_editor: None,
10712 progression_curve: None,
10713 };
10714 state.player = state.entities.first().cloned();
10715 state
10716 }
10717
10718 #[test]
10719 fn whisper_cancels_when_peer_walks_out_of_range() {
10720 let mut state = sample_state();
10721 state.player = state.entities.first().cloned();
10722 let mut peer = state.entities[0].clone();
10723 peer.id = 2;
10724 peer.label = "Ada".into();
10725 peer.transform.position = WorldCoord::surface(129.0, 128.0); state.entities.push(peer.clone());
10727 state.social_chat.focus_whisper(2, "Ada");
10728 state.refresh_whisper_range();
10729 assert!(matches!(
10730 state.social_chat.thread,
10731 crate::social::ChatThreadKind::Whisper { peer: 2 }
10732 ));
10733
10734 peer.transform.position = WorldCoord::surface(132.0, 128.0); state.entities[1] = peer;
10736 state.refresh_whisper_range();
10737 assert_eq!(
10738 state.social_chat.thread,
10739 crate::social::ChatThreadKind::Nearby
10740 );
10741 assert!(!state.social_chat.input_focused);
10742 }
10743
10744 #[test]
10745 fn probe_use_world_npc_beats_nearby_loot() {
10746 let mut state = sample_state();
10747 state.npcs.push(flatland_protocol::NpcView {
10748 id: "ada".into(),
10749 label: "Ada".into(),
10750 role: "broker".into(),
10751 x: 129.0,
10752 y: 128.0,
10753 building_id: None,
10754 entity_id: None,
10755 life_state: None,
10756 hp_pct: None,
10757 can_trade: true,
10758 tile_id: None,
10759 behavior_state: None,
10760 presentation_state: None,
10761 sprite_mode: None,
10762 paperdoll_ref: None,
10763 });
10764 state.ground_drops.push(flatland_protocol::GroundDropView {
10765 id: "d1".into(),
10766 template_id: "lumber".into(),
10767 quantity: 1,
10768 x: 128.5,
10769 y: 128.0,
10770 z: 0.0,
10771 tile_id: None,
10772 paperdoll_ref: None,
10773 yaw: 0.0,
10774 pitch: 0.0,
10775 roll: 0.0,
10776 draw_scale: 1.0,
10777 });
10778 let probe = state.probe_use_world();
10779 let primary = probe.primary.expect("primary");
10780 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
10781 assert_eq!(primary.id, "ada");
10782 }
10783
10784 #[test]
10785 fn probe_use_world_harvest_when_in_range() {
10786 let state = sample_state(); let probe = state.probe_use_world();
10788 assert!(
10789 probe.primary.is_none(),
10790 "oak is 2m away, out of harvest range"
10791 );
10792 assert!(probe
10793 .candidates
10794 .iter()
10795 .any(|c| c.kind == crate::UseWorldKind::Harvest));
10796
10797 let mut state = sample_state();
10798 state.resource_nodes[0].x = 129.0;
10799 let probe = state.probe_use_world();
10800 let primary = probe.primary.expect("primary");
10801 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
10802 }
10803
10804 #[test]
10805 fn probe_use_world_door_uses_building_label() {
10806 let mut state = sample_state();
10807 state.doors[0].x = 129.0;
10808 state.doors[0].y = 128.0;
10809 let probe = state.probe_use_world();
10810 let primary = probe.primary.expect("primary");
10811 assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
10812 assert_eq!(primary.label, "Broker");
10813 assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
10814 }
10815
10816 #[test]
10817 fn empty_entity_tick_preserves_welcome_snapshot() {
10818 let mut state = sample_state();
10819 state.inventory.insert("carrot".into(), 3);
10820 let delta = TickDelta {
10821 tick: 1,
10822 entities: vec![],
10823 resource_nodes: vec![],
10824 ground_drops: vec![],
10825 placed_containers: vec![],
10826 buildings: vec![],
10827 doors: vec![],
10828 interior_map: None,
10829 npcs: vec![],
10830 inventory: vec![],
10831 blueprints: vec![],
10832 world_clock: flatland_protocol::WorldClock::default(),
10833 combat: None,
10834 quest_log: vec![],
10835 hired_workers: Vec::new(),
10836 interactables: vec![],
10837 ledger: None,
10838 career: None,
10839 combat_fx: Vec::new(),
10840 };
10841
10842 state.apply_tick_fields(&delta, 1);
10843
10844 assert_eq!(state.entities.len(), 1);
10845 assert!(state.player.is_some());
10846 assert_eq!(state.inventory.get("carrot"), Some(&3));
10847 assert_eq!(state.resource_nodes.len(), 1);
10848 }
10849
10850 #[test]
10851 fn tick_preserves_world_layers_when_delta_omits_them() {
10852 let mut state = sample_state();
10853 let delta = TickDelta {
10854 tick: 1,
10855 entities: state.entities.clone(),
10856 resource_nodes: vec![],
10857 ground_drops: vec![],
10858 placed_containers: vec![],
10859 buildings: vec![],
10860 doors: vec![],
10861 interior_map: None,
10862 npcs: vec![],
10863 inventory: vec![],
10864 blueprints: vec![],
10865 world_clock: flatland_protocol::WorldClock::default(),
10866 combat: None,
10867 quest_log: vec![],
10868 hired_workers: Vec::new(),
10869 interactables: vec![],
10870 ledger: None,
10871 career: None,
10872 combat_fx: Vec::new(),
10873 };
10874
10875 state.apply_tick_fields(&delta, 1);
10876
10877 assert_eq!(state.resource_nodes.len(), 1);
10878 assert_eq!(state.buildings.len(), 1);
10879 assert_eq!(state.doors.len(), 1);
10880 }
10881
10882 #[test]
10883 fn tick_updates_resource_nodes_when_server_sends_them() {
10884 let mut state = sample_state();
10885 let delta = TickDelta {
10886 tick: 1,
10887 entities: state.entities.clone(),
10888 resource_nodes: vec![ResourceNodeView {
10889 id: "oak-1".into(),
10890 label: "Oak".into(),
10891 x: 130.0,
10892 y: 128.0,
10893 z: 0.0,
10894 item_template: "oak_log".into(),
10895 state: ResourceNodeState::Cooldown,
10896 blocking: true,
10897 blocking_radius_m: 0.8,
10898 tile_id: None,
10899 paperdoll_ref: None,
10900 yaw: 0.0,
10901 pitch: 0.0,
10902 roll: 0.0,
10903 draw_scale: 1.0,
10904 sprite_mode: None,
10905 presentation_state: None,
10906 }],
10907 buildings: vec![],
10908 doors: vec![],
10909 interior_map: None,
10910 npcs: vec![],
10911 inventory: vec![],
10912 blueprints: vec![],
10913 world_clock: flatland_protocol::WorldClock::default(),
10914 ground_drops: vec![],
10915 placed_containers: vec![],
10916 combat: None,
10917 quest_log: vec![],
10918 hired_workers: Vec::new(),
10919 interactables: vec![],
10920 ledger: None,
10921 career: None,
10922 combat_fx: Vec::new(),
10923 };
10924
10925 state.apply_tick_fields(&delta, 1);
10926
10927 assert!(matches!(
10928 state.resource_nodes[0].state,
10929 ResourceNodeState::Cooldown
10930 ));
10931 }
10932
10933 #[test]
10934 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
10935 let mut state = GameState {
10936 session_id: 1,
10937 entity_id: 1,
10938 character_id: None,
10939 tick: 0,
10940 chunk_rev: 0,
10941 content_rev: 0,
10942 publish_rev: 0,
10943 entities: vec![EntityState {
10944 id: 1,
10945 label: "You".into(),
10946 transform: Transform {
10947 position: WorldCoord::surface(4.5, 2.0),
10948 yaw: 0.0,
10949 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
10950 },
10951 vitals: None,
10952 attributes: None,
10953 skills: None,
10954 inside_building: Some("broker_hut".into()),
10955 tile_id: None,
10956 paperdoll_ref: None,
10957 presentation_state: None,
10958 sprite_mode: None,
10959 progression_xp: None,
10960 }],
10961 player: None,
10962 resource_nodes: vec![],
10963 ground_drops: vec![],
10964 placed_containers: vec![],
10965 buildings: vec![BuildingView {
10966 id: "broker_hut".into(),
10967 label: "Broker".into(),
10968 x: 158.0,
10969 y: 124.0,
10970 width_m: 8.0,
10971 depth_m: 6.0,
10972 interior_blueprint: Some("broker_hut".into()),
10973 tags: vec![],
10974 }],
10975 doors: vec![flatland_protocol::DoorView {
10976 id: "broker_hut_exit".into(),
10977 building_id: "broker_hut".into(),
10978 x: 4.3,
10979 y: 0.9,
10980 open: true,
10981 portal: Some("front".into()),
10982 }],
10983 interior_map: None,
10984 npcs: vec![flatland_protocol::NpcView {
10985 id: "ada_broker".into(),
10986 label: "Ada".into(),
10987 x: 4.5,
10988 y: 2.0,
10989 building_id: Some("broker_hut".into()),
10990 role: "broker".into(),
10991 entity_id: None,
10992 life_state: None,
10993 hp_pct: None,
10994 can_trade: true,
10995 tile_id: None,
10996 behavior_state: None,
10997 presentation_state: None,
10998 sprite_mode: None,
10999 paperdoll_ref: None,
11000 }],
11001 blueprints: vec![],
11002 world_width_m: 256.0,
11003 world_height_m: 256.0,
11004 terrain_zones: Vec::new(),
11005 z_platforms: Vec::new(),
11006 z_transitions: Vec::new(),
11007 world_clock: flatland_protocol::WorldClock::default(),
11008 inventory: std::collections::HashMap::new(),
11009 inventory_hints: std::collections::HashMap::new(),
11010 logs: VecDeque::new(),
11011 intents_sent: 0,
11012 ticks_received: 0,
11013 connected: true,
11014 disconnect_reason: None,
11015 show_stats: false,
11016 hud_log_hidden: false,
11017 show_equip_menu: false,
11018 equip_menu_index: 0,
11019 show_craft_menu: false,
11020 craft_menu_index: 0,
11021 craft_batch_quantity: 1,
11022 show_shop_menu: false,
11023 shop_catalog: None,
11024 bank_panel: None,
11025 bank_menu_index: 0,
11026 bank_ui_mode: BankUiMode::Menu,
11027 storage_panel: None,
11028 storage_menu_index: 0,
11029 storage_ui_mode: StorageUiMode::Menu,
11030 shop_tab: ShopTab::default(),
11031 shop_menu_index: 0,
11032 shop_quantity: 1,
11033 shop_trade_log: VecDeque::new(),
11034 show_npc_verb_menu: false,
11035 npc_verb_target: None,
11036 npc_verb_index: 0,
11037 player_verbs: crate::social::PlayerVerbState::default(),
11038 social_chat: crate::social::SocialChatState::default(),
11039 trade_ui: crate::social::TradeUiState::default(),
11040 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
11041 show_npc_chat: false,
11042 npc_chat: None,
11043 show_inventory_menu: false,
11044 inventory_menu_index: 0,
11045 inventory_tab: InventoryTab::OnPerson,
11046 inventory_filter: String::new(),
11047 inventory_filter_focused: false,
11048 show_move_picker: false,
11049 show_rename_prompt: false,
11050 show_worker_rename: false,
11051 rename_buffer: String::new(),
11052 move_picker_index: 0,
11053 move_picker: None,
11054 show_grant_picker: false,
11055 grant_picker_index: 0,
11056 grant_picker: None,
11057 show_destroy_picker: false,
11058 destroy_confirm_pending: false,
11059 destroy_picker: None,
11060 combat_target: None,
11061 combat_target_label: None,
11062 combat_fx: Vec::new(),
11063 in_combat: false,
11064 auto_attack: true,
11065 combat_has_los: false,
11066 attack_cd_ticks: 0,
11067 gcd_ticks: 0,
11068 weapon_ability_id: "unarmed".into(),
11069 mainhand_template_id: None,
11070 mainhand_label: None,
11071 offhand_template_id: None,
11072 offhand_label: None,
11073 mainhand_hand_slots: 1,
11074 defense: None,
11075 worn: BTreeMap::new(),
11076 carry_mass: 0.0,
11077 carry_mass_max: 0.0,
11078 encumbrance: flatland_protocol::EncumbranceState::Light,
11079 inventory_stacks: Vec::new(),
11080 keychain_stacks: Vec::new(),
11081 whisper_pouch_stacks: Vec::new(),
11082 combat_target_detail: None,
11083 statuses: Vec::new(),
11084 cast_progress: None,
11085 ability_cooldowns: Vec::new(),
11086 blocking_active: false,
11087 max_target_slots: 1,
11088 combat_slots: Vec::new(),
11089 rotation_presets: Vec::new(),
11090 known_abilities: Vec::new(),
11091 hotbar: vec![None; 9],
11092 max_abilities_per_rotation: 0,
11093 show_loadout_menu: false,
11094 show_keychain_menu: false,
11095 keychain_menu_index: 0,
11096 show_rotation_editor: false,
11097 loadout_menu_index: 0,
11098 loadout_hotbar_slot: 1,
11099 loadout_ability_index: 0,
11100 loadout_focus_presets: false,
11101 rotation_editor: RotationEditorState::default(),
11102 harvest_in_progress: false,
11103 harvest_started_at: None,
11104 pending_craft_ack: None,
11105 pending_worker_job_ack: None,
11106 quest_log: Vec::new(),
11107 interactables: Vec::new(),
11108 ledger: None,
11109 career: None,
11110 character_sheet_tab: CharacterSheetTab::Character,
11111 ledger_period: LedgerPeriod::Day,
11112 show_quest_offer: false,
11113 pending_quest_offer: None,
11114 show_quest_menu: false,
11115 quest_menu_index: 0,
11116 quest_withdraw_confirm: false,
11117 hired_workers: Vec::new(),
11118 show_workers_menu: false,
11119 workers_menu_index: 0,
11120 workers_menu_compact: false,
11121 worker_step_display: BTreeMap::new(),
11122 worker_error_display: BTreeMap::new(),
11123 show_worker_give_picker: false,
11124 worker_give_picker_index: 0,
11125 worker_give_picker: None,
11126 show_worker_give_target_picker: false,
11127 worker_give_target_picker_index: 0,
11128 worker_give_target_picker: None,
11129 show_worker_take_picker: false,
11130 worker_take_picker_index: 0,
11131 worker_take_picker: None,
11132 show_worker_teach_picker: false,
11133 worker_teach_picker_index: 0,
11134 worker_teach_picker: None,
11135 worker_route_editor: None,
11136 progression_curve: None,
11137 };
11138 state.player = state.entities.first().cloned();
11139 assert_eq!(
11140 state.nearest_interact_target().as_deref(),
11141 Some("ada_broker")
11142 );
11143 }
11144
11145 #[test]
11146 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
11147 let mut state = sample_state();
11148 state.placed_containers = vec![
11151 flatland_protocol::PlacedContainerView {
11152 id: "near".into(),
11153 template_id: "wooden_chest_small".into(),
11154 display_name: "Wooden Chest".into(),
11155 x: 130.0,
11156 y: 128.0,
11157 z: 0.0,
11158 locked: true,
11159 accessible: true,
11160 owner_character_id: None,
11161 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
11162 lock_id: None,
11163 capacity_volume: None,
11164 item_instance_id: Some(uuid::Uuid::from_u128(1)),
11165 tile_id: None,
11166 paperdoll_ref: None,
11167 worker_lodging_capacity: None,
11168 },
11169 flatland_protocol::PlacedContainerView {
11170 id: "far".into(),
11171 template_id: "wooden_chest_small".into(),
11172 display_name: "Distant Chest".into(),
11173 x: 128.0 + CONTAINER_RANGE_M + 5.0,
11174 y: 128.0,
11175 z: 0.0,
11176 locked: false,
11177 accessible: true,
11178 owner_character_id: None,
11179 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
11180 lock_id: None,
11181 capacity_volume: None,
11182 item_instance_id: Some(uuid::Uuid::from_u128(2)),
11183 tile_id: None,
11184 paperdoll_ref: None,
11185 worker_lodging_capacity: None,
11186 },
11187 ];
11188
11189 let nearby = state.nearby_containers();
11190 assert_eq!(
11191 nearby.len(),
11192 1,
11193 "far chest must not appear once out of range"
11194 );
11195 assert_eq!(nearby[0].view.id, "near");
11196 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
11197 assert!(nearby[0].rows[0].is_chest_shell);
11198
11199 state.placed_containers[0].accessible = false;
11202 let nearby = state.nearby_containers();
11203 assert_eq!(nearby.len(), 1);
11204 assert_eq!(nearby[0].rows.len(), 1);
11205 assert!(nearby[0].rows[0].is_chest_shell);
11206 }
11207
11208 #[test]
11209 fn chest_pickup_destinations_offer_person_and_worn_bag() {
11210 let mut state = sample_state();
11211 let back_id = uuid::Uuid::from_u128(42);
11212 state.worn.insert(
11213 BodySlot::Back,
11214 flatland_protocol::ItemStack {
11215 template_id: "travel_backpack".into(),
11216 quantity: 1,
11217 item_instance_id: Some(back_id),
11218 props: Default::default(),
11219 status_bindings: Vec::new(),
11220 contents: Vec::new(),
11221 display_name: Some("Travel Backpack".into()),
11222 category: Some("container".into()),
11223 base_mass: Some(2.5),
11224 base_volume: Some(12.0),
11225 capacity_volume: Some(80.0),
11226 stackable: Some(false),
11227 world_placeable: Some(false),
11228 worker_lodging_capacity: None,
11229 equip_slot: None,
11230 armor_physical: None,
11231 resists: vec![],
11232 hand_slots: None,
11233 },
11234 );
11235 let opts = state.chest_pickup_destinations("chest-1");
11236 assert!(opts.iter().any(|o| matches!(
11237 &o.kind,
11238 MoveOptionKind::PickupPlaced {
11239 nest_parent_instance_id: None,
11240 ..
11241 }
11242 )));
11243 assert!(opts.iter().any(|o| matches!(
11244 &o.kind,
11245 MoveOptionKind::PickupPlaced {
11246 nest_parent_instance_id: Some(id),
11247 ..
11248 } if *id == back_id
11249 )));
11250 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
11251 }
11252
11253 #[test]
11254 fn placed_container_public_label_hides_owner_custom_name() {
11255 let owner = uuid::Uuid::from_u128(99);
11256 let mut state = sample_state();
11257 state.character_id = Some(uuid::Uuid::from_u128(1));
11258 state.inventory_hints.insert(
11259 "wooden_chest_medium".into(),
11260 InventoryHint {
11261 display_name: "Medium Wooden Chest".into(),
11262 category: "container".into(),
11263 base_mass: None,
11264 base_volume: None,
11265 capacity_volume: None,
11266 stackable: false,
11267 },
11268 );
11269 let chest = flatland_protocol::PlacedContainerView {
11270 id: "c1".into(),
11271 template_id: "wooden_chest_medium".into(),
11272 display_name: "Barry's Loot #a3f2".into(),
11273 x: 128.0,
11274 y: 128.0,
11275 z: 0.0,
11276 locked: false,
11277 accessible: true,
11278 owner_character_id: Some(owner),
11279 contents: vec![],
11280 lock_id: None,
11281 capacity_volume: None,
11282 item_instance_id: None,
11283 tile_id: None,
11284 paperdoll_ref: None,
11285 worker_lodging_capacity: None,
11286 };
11287 assert_eq!(
11288 state.placed_container_public_label(&chest),
11289 "Medium Wooden Chest"
11290 );
11291 state.character_id = Some(owner);
11292 assert_eq!(
11293 state.placed_container_public_label(&chest),
11294 "Barry's Loot #a3f2"
11295 );
11296 }
11297
11298 #[test]
11299 fn location_context_lists_nearby_resource_node() {
11300 let mut state = sample_state();
11301 state.player = state.entities.first().cloned();
11302 state.resource_nodes[0].x = 128.2;
11303 state.resource_nodes[0].y = 128.0;
11304 let lines = state.location_context_lines();
11305 assert!(
11306 lines
11307 .iter()
11308 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
11309 "expected resource node in context: {:?}",
11310 lines
11311 );
11312 }
11313
11314 #[test]
11315 fn quest_board_usable_within_board_radius() {
11316 let mut state = sample_state();
11317 state.player = state.entities.first().cloned();
11318 state.interactables = vec![flatland_protocol::InteractableView {
11319 id: "board-1".into(),
11320 kind: "quest_board".into(),
11321 label: "Town Quest Board".into(),
11322 x: 130.5,
11323 y: 128.0,
11324 z: 0.0,
11325 board_id: Some("starter_town_board".into()),
11326 }];
11327 assert_eq!(
11329 state.nearest_interact_target().as_deref(),
11330 Some("board-1"),
11331 "quest board should be selectable at ~2.5m"
11332 );
11333 let lines = state.location_context_lines();
11334 assert!(
11335 lines
11336 .iter()
11337 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
11338 "HUD should advertise f when board is in range: {:?}",
11339 lines
11340 );
11341 }
11342
11343 #[test]
11344 fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
11345 let mut state = sample_state();
11346 state.worn.insert(
11347 BodySlot::Back,
11348 flatland_protocol::ItemStack {
11349 template_id: "travel_backpack".into(),
11350 quantity: 1,
11351 item_instance_id: Some(uuid::Uuid::from_u128(3)),
11352 props: Default::default(),
11353 status_bindings: Vec::new(),
11354 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
11355 display_name: None,
11356 category: None,
11357 base_mass: None,
11358 base_volume: None,
11359 capacity_volume: None,
11360 stackable: None,
11361 world_placeable: None,
11362 worker_lodging_capacity: None,
11363 equip_slot: None,
11364 armor_physical: None,
11365 resists: vec![],
11366 hand_slots: None,
11367 },
11368 );
11369 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
11370 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11371 id: "chest-1".into(),
11372 template_id: "wooden_chest_small".into(),
11373 display_name: "Wooden Chest".into(),
11374 x: 129.0,
11375 y: 128.0,
11376 z: 0.0,
11377 locked: false,
11378 accessible: true,
11379 owner_character_id: None,
11380 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
11381 lock_id: None,
11382 capacity_volume: None,
11383 item_instance_id: Some(uuid::Uuid::from_u128(4)),
11384 tile_id: None,
11385 paperdoll_ref: None,
11386 worker_lodging_capacity: None,
11387 }];
11388
11389 state.inventory_tab = InventoryTab::OnPerson;
11390 let rows = state.inventory_selectable_rows();
11391 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
11392 assert_eq!(
11393 sections,
11394 vec![
11395 InventorySection::Worn, InventorySection::Worn, InventorySection::Person, ]
11399 );
11400 assert_eq!(rows[0].stack.template_id, "travel_backpack");
11401 assert!(rows[0].is_equip_shell);
11402 assert_eq!(rows[1].stack.template_id, "iron_ore");
11403 assert_eq!(rows[1].depth, 1);
11404 assert_eq!(rows[2].stack.template_id, "lumber");
11405
11406 let lines = state.inventory_browser_lines();
11407 assert!(lines.iter().any(|l| matches!(
11408 l,
11409 InventoryBrowserLine::Section(s) if s.contains("Worn")
11410 )));
11411 assert!(lines.iter().any(|l| matches!(
11412 l,
11413 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
11414 || text.contains("backpack")
11415 )));
11416 assert!(!lines.iter().any(|l| matches!(
11417 l,
11418 InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
11419 )));
11420
11421 state.inventory_tab = InventoryTab::Nearby;
11422 let nearby_rows = state.inventory_selectable_rows();
11423 assert_eq!(nearby_rows.len(), 2);
11424 assert!(nearby_rows[0].is_chest_shell);
11425 assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
11426 let nearby_lines = state.inventory_browser_lines();
11427 assert!(nearby_lines.iter().any(|l| matches!(
11428 l,
11429 InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
11430 )));
11431 }
11432
11433 #[test]
11434 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
11435 let mut state = sample_state();
11436 let back_id = uuid::Uuid::from_u128(5);
11437 state.worn.insert(
11438 BodySlot::Back,
11439 flatland_protocol::ItemStack {
11440 template_id: "travel_backpack".into(),
11441 quantity: 1,
11442 item_instance_id: Some(back_id),
11443 props: Default::default(),
11444 status_bindings: Vec::new(),
11445 contents: Vec::new(),
11446 display_name: None,
11447 category: Some("container".into()),
11448 base_mass: None,
11449 base_volume: None,
11450 capacity_volume: Some(80.0),
11451 stackable: None,
11452 world_placeable: None,
11453 worker_lodging_capacity: None,
11454 equip_slot: None,
11455 armor_physical: None,
11456 resists: vec![],
11457 hand_slots: None,
11458 },
11459 );
11460 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11461 id: "chest-1".into(),
11462 template_id: "wooden_chest_small".into(),
11463 display_name: "Wooden Chest".into(),
11464 x: 129.0,
11465 y: 128.0,
11466 z: 0.0,
11467 locked: false,
11468 accessible: true,
11469 owner_character_id: None,
11470 contents: Vec::new(),
11471 lock_id: None,
11472 capacity_volume: None,
11473 item_instance_id: Some(uuid::Uuid::from_u128(6)),
11474 tile_id: None,
11475 paperdoll_ref: None,
11476 worker_lodging_capacity: None,
11477 }];
11478
11479 let opts = state.move_destinations_for(
11482 &flatland_protocol::InventoryLocation::Root,
11483 None,
11484 None,
11485 "lumber",
11486 );
11487 assert!(!opts.iter().any(|o| matches!(
11488 &o.kind,
11489 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
11490 )));
11491 assert!(opts.iter().any(|o| matches!(
11492 &o.kind,
11493 MoveOptionKind::Move { location, parent_instance_id, .. }
11494 if *location == flatland_protocol::InventoryLocation::Worn {
11495 slot: BodySlot::Back,
11496 } && *parent_instance_id == Some(back_id)
11497 )));
11498 assert!(opts.iter().any(|o| matches!(
11499 &o.kind,
11500 MoveOptionKind::Move { location, .. }
11501 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
11502 )));
11503 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
11504 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
11505
11506 let from_backpack = flatland_protocol::InventoryLocation::Worn {
11510 slot: BodySlot::Back,
11511 };
11512 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
11513 assert!(!opts.iter().any(|o| matches!(
11514 &o.kind,
11515 MoveOptionKind::Move { location, parent_instance_id, .. }
11516 if *location == from_backpack && *parent_instance_id == Some(back_id)
11517 )));
11518 assert!(opts.iter().any(|o| matches!(
11519 &o.kind,
11520 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
11521 )));
11522 }
11523
11524 #[test]
11525 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
11526 let mut state = sample_state();
11527 state.worn.insert(
11530 BodySlot::Waist,
11531 flatland_protocol::ItemStack {
11532 template_id: "simple_belt".into(),
11533 quantity: 1,
11534 item_instance_id: Some(uuid::Uuid::from_u128(10)),
11535 props: Default::default(),
11536 status_bindings: Vec::new(),
11537 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
11538 display_name: None,
11539 category: Some("container".into()),
11540 base_mass: None,
11541 base_volume: None,
11542 capacity_volume: None,
11543 stackable: None,
11544 world_placeable: None,
11545 worker_lodging_capacity: None,
11546 equip_slot: None,
11547 armor_physical: None,
11548 resists: vec![],
11549 hand_slots: None,
11550 },
11551 );
11552 state.worn.insert(
11553 BodySlot::Head,
11554 flatland_protocol::ItemStack {
11555 template_id: "cloth_cap".into(),
11556 quantity: 1,
11557 item_instance_id: Some(uuid::Uuid::from_u128(11)),
11558 props: Default::default(),
11559 status_bindings: Vec::new(),
11560 contents: Vec::new(),
11561 display_name: None,
11562 category: Some("armor".into()),
11563 base_mass: None,
11564 base_volume: None,
11565 capacity_volume: None,
11566 stackable: None,
11567 world_placeable: None,
11568 worker_lodging_capacity: None,
11569 equip_slot: None,
11570 armor_physical: None,
11571 resists: vec![],
11572 hand_slots: None,
11573 },
11574 );
11575 state.worn.insert(
11576 BodySlot::Back,
11577 flatland_protocol::ItemStack {
11578 template_id: "travel_backpack".into(),
11579 quantity: 1,
11580 item_instance_id: Some(uuid::Uuid::from_u128(12)),
11581 props: Default::default(),
11582 status_bindings: Vec::new(),
11583 contents: Vec::new(),
11584 display_name: None,
11585 category: Some("container".into()),
11586 base_mass: None,
11587 base_volume: None,
11588 capacity_volume: None,
11589 stackable: None,
11590 world_placeable: None,
11591 worker_lodging_capacity: None,
11592 equip_slot: None,
11593 armor_physical: None,
11594 resists: vec![],
11595 hand_slots: None,
11596 },
11597 );
11598
11599 let rows = state.worn_rows();
11600 assert_eq!(rows.len(), 4);
11602 assert_eq!(rows[0].stack.template_id, "cloth_cap");
11603 assert!(rows[0].is_equip_shell);
11604 assert_eq!(rows[1].stack.template_id, "travel_backpack");
11605 assert!(rows[1].is_equip_shell);
11606 assert_eq!(rows[2].stack.template_id, "simple_belt");
11607 assert!(rows[2].is_equip_shell);
11608 assert_eq!(rows[3].stack.template_id, "leather_pouch");
11609 assert_eq!(rows[3].depth, 1);
11610 assert!(!rows[3].is_equip_shell);
11611 }
11612
11613 #[test]
11614 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
11615 let mut state = sample_state();
11616 state.worn.insert(
11617 BodySlot::Waist,
11618 flatland_protocol::ItemStack {
11619 template_id: "simple_belt".into(),
11620 quantity: 1,
11621 item_instance_id: Some(uuid::Uuid::from_u128(20)),
11622 props: Default::default(),
11623 status_bindings: Vec::new(),
11624 contents: Vec::new(),
11625 display_name: Some("Simple Belt".into()),
11626 category: Some("container".into()),
11627 base_mass: None,
11628 base_volume: None,
11629 capacity_volume: None,
11630 stackable: None,
11631 world_placeable: None,
11632 worker_lodging_capacity: None,
11633 equip_slot: None,
11634 armor_physical: None,
11635 resists: vec![],
11636 hand_slots: None,
11637 },
11638 );
11639 state.worn.insert(
11640 BodySlot::Head,
11641 flatland_protocol::ItemStack {
11642 template_id: "cloth_cap".into(),
11643 quantity: 1,
11644 item_instance_id: Some(uuid::Uuid::from_u128(21)),
11645 props: Default::default(),
11646 status_bindings: Vec::new(),
11647 contents: Vec::new(),
11648 display_name: Some("Cloth Cap".into()),
11649 category: Some("armor".into()),
11650 base_mass: None,
11651 base_volume: None,
11652 capacity_volume: None,
11653 stackable: None,
11654 world_placeable: None,
11655 worker_lodging_capacity: None,
11656 equip_slot: None,
11657 armor_physical: None,
11658 resists: vec![],
11659 hand_slots: None,
11660 },
11661 );
11662
11663 let opts = state.move_destinations_for(
11664 &flatland_protocol::InventoryLocation::Root,
11665 None,
11666 None,
11667 "leather_pouch",
11668 );
11669 assert!(
11670 opts.iter().any(|o| matches!(
11671 &o.kind,
11672 MoveOptionKind::Move { location, .. }
11673 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
11674 )),
11675 "belt loop must be offered when moving a pouch"
11676 );
11677 assert!(
11678 !opts.iter().any(|o| matches!(
11679 &o.kind,
11680 MoveOptionKind::Move { location, .. }
11681 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
11682 )),
11683 "armor slots can't hold other items and must not appear as move destinations"
11684 );
11685 let belt_opt = opts
11686 .iter()
11687 .find(|o| matches!(
11688 &o.kind,
11689 MoveOptionKind::Move { location, .. }
11690 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
11691 ))
11692 .unwrap();
11693 assert!(belt_opt.label.contains("belt loop"));
11694
11695 let opts = state.move_destinations_for(
11696 &flatland_protocol::InventoryLocation::Root,
11697 None,
11698 None,
11699 "lumber",
11700 );
11701 assert!(
11702 !opts.iter().any(|o| o.label.contains("belt loop")),
11703 "loose materials must not target the belt shell — only nested pouches"
11704 );
11705 }
11706
11707 #[test]
11708 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
11709 let mut state = sample_state();
11710 let belt_id = uuid::Uuid::from_u128(30);
11711 let pouch_id = uuid::Uuid::from_u128(31);
11712 state.worn.insert(
11713 BodySlot::Waist,
11714 flatland_protocol::ItemStack {
11715 template_id: "simple_belt".into(),
11716 quantity: 1,
11717 item_instance_id: Some(belt_id),
11718 props: Default::default(),
11719 status_bindings: Vec::new(),
11720 world_placeable: None,
11721 worker_lodging_capacity: None,
11722 equip_slot: None,
11723 armor_physical: None,
11724 resists: vec![],
11725 hand_slots: None,
11726 contents: vec![flatland_protocol::ItemStack {
11727 template_id: "dimensional_pouch".into(),
11728 quantity: 1,
11729 item_instance_id: Some(pouch_id),
11730 props: Default::default(),
11731 status_bindings: Vec::new(),
11732 contents: Vec::new(),
11733 display_name: Some("Dimensional Pouch".into()),
11734 category: Some("container".into()),
11735 base_mass: None,
11736 base_volume: None,
11737 capacity_volume: Some(200.0),
11738 stackable: None,
11739 world_placeable: None,
11740 worker_lodging_capacity: None,
11741 equip_slot: None,
11742 armor_physical: None,
11743 resists: vec![],
11744 hand_slots: None,
11745 }],
11746 display_name: Some("Simple Belt".into()),
11747 category: Some("container".into()),
11748 base_mass: None,
11749 base_volume: None,
11750 capacity_volume: None,
11751 stackable: None,
11752 },
11753 );
11754
11755 let opts = state.move_destinations_for(
11756 &flatland_protocol::InventoryLocation::Root,
11757 None,
11758 None,
11759 "iron_ore",
11760 );
11761 assert!(
11762 opts.iter().any(|o| matches!(
11763 &o.kind,
11764 MoveOptionKind::Move {
11765 location,
11766 parent_instance_id,
11767 ..
11768 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
11769 && *parent_instance_id == Some(pouch_id)
11770 )),
11771 "dimensional pouch clipped on belt must accept loose items"
11772 );
11773 assert!(
11774 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
11775 "destination label should name the pouch"
11776 );
11777 }
11778
11779 #[test]
11780 fn container_volume_label_on_placed_chest_shell() {
11781 let mut state = sample_state();
11782 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11783 id: "chest-1".into(),
11784 template_id: "wooden_chest_small".into(),
11785 display_name: "Camp Chest".into(),
11786 worker_lodging_capacity: None,
11787 x: 129.0,
11788 y: 128.0,
11789 z: 0.0,
11790 locked: false,
11791 accessible: true,
11792 owner_character_id: None,
11793 contents: vec![flatland_protocol::ItemStack {
11794 template_id: "iron_ore".into(),
11795 quantity: 2,
11796 item_instance_id: None,
11797 props: Default::default(),
11798 status_bindings: Vec::new(),
11799 contents: Vec::new(),
11800 display_name: None,
11801 category: None,
11802 base_mass: None,
11803 base_volume: Some(2.0),
11804 capacity_volume: None,
11805 stackable: None,
11806 world_placeable: None,
11807 worker_lodging_capacity: None,
11808 equip_slot: None,
11809 armor_physical: None,
11810 resists: vec![],
11811 hand_slots: None,
11812 }],
11813 lock_id: None,
11814 capacity_volume: Some(60.0),
11815 item_instance_id: Some(uuid::Uuid::from_u128(4)),
11816 tile_id: None,
11817 paperdoll_ref: None,
11818 }];
11819 let nearby = state.nearby_containers();
11820 let label = state.container_volume_label(&nearby[0].rows[0]);
11821 assert!(
11822 label.contains("vol 4/60"),
11823 "expected used/cap in label, got {label}"
11824 );
11825 assert!(
11826 label.contains("56 free"),
11827 "expected free space, got {label}"
11828 );
11829 }
11830
11831 #[test]
11832 fn key_pair_chest_label_from_placed_lock_id() {
11833 let mut state = sample_state();
11834 let owner = uuid::Uuid::from_u128(77);
11835 state.character_id = Some(owner);
11836 let lock = uuid::Uuid::from_u128(99).to_string();
11837 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11838 id: "chest-1".into(),
11839 template_id: "wooden_chest_small".into(),
11840 display_name: "Barry's Loot #a3f2".into(),
11841 x: 129.0,
11842 y: 128.0,
11843 z: 0.0,
11844 locked: true,
11845 accessible: true,
11846 owner_character_id: Some(owner),
11847 contents: Vec::new(),
11848 lock_id: Some(lock.clone()),
11849 capacity_volume: None,
11850 item_instance_id: Some(uuid::Uuid::from_u128(4)),
11851 tile_id: None,
11852 paperdoll_ref: None,
11853 worker_lodging_capacity: None,
11854 }];
11855 let key_id = uuid::Uuid::from_u128(5);
11856 let key = flatland_protocol::ItemStack {
11857 template_id: KEY_TEMPLATE.into(),
11858 quantity: 1,
11859 item_instance_id: Some(key_id),
11860 props: BTreeMap::from([
11861 (PROP_OPENS_LOCK_ID.into(), lock),
11862 (
11863 PROP_OPENS_CONTAINER_NAME.into(),
11864 "Barry's Loot #a3f2".into(),
11865 ),
11866 ]),
11867 status_bindings: Vec::new(),
11868 contents: Vec::new(),
11869 display_name: Some("Container Key".into()),
11870 category: Some("key".into()),
11871 base_mass: None,
11872 base_volume: None,
11873 capacity_volume: None,
11874 stackable: None,
11875 world_placeable: None,
11876 worker_lodging_capacity: None,
11877 equip_slot: None,
11878 armor_physical: None,
11879 resists: vec![],
11880 hand_slots: None,
11881 };
11882 state.inventory_stacks = vec![key.clone()];
11883 assert_eq!(
11884 state.key_pair_chest_label(&key).as_deref(),
11885 Some("Barry's Loot #a3f2")
11886 );
11887 assert!(state.key_drop_blocked(&key));
11888 }
11889
11890 #[test]
11891 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
11892 let mut state = sample_state();
11893 let lock = uuid::Uuid::from_u128(101).to_string();
11894 let key = flatland_protocol::ItemStack {
11895 template_id: KEY_TEMPLATE.into(),
11896 quantity: 1,
11897 item_instance_id: Some(uuid::Uuid::from_u128(7)),
11898 props: BTreeMap::from([
11899 (PROP_OPENS_LOCK_ID.into(), lock),
11900 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
11901 ]),
11902 status_bindings: Vec::new(),
11903 contents: Vec::new(),
11904 display_name: None,
11905 category: Some("key".into()),
11906 base_mass: None,
11907 base_volume: None,
11908 capacity_volume: None,
11909 stackable: None,
11910 world_placeable: None,
11911 worker_lodging_capacity: None,
11912 equip_slot: None,
11913 armor_physical: None,
11914 resists: vec![],
11915 hand_slots: None,
11916 };
11917 state.placed_containers.clear();
11918 assert_eq!(
11919 state.key_pair_chest_label(&key).as_deref(),
11920 Some("Camp Stash")
11921 );
11922 }
11923
11924 #[test]
11925 fn key_drop_allowed_when_paired_chest_unlocked() {
11926 let mut state = sample_state();
11927 let lock = uuid::Uuid::from_u128(100).to_string();
11928 let key_id = uuid::Uuid::from_u128(6);
11929 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11930 id: "chest-1".into(),
11931 template_id: "wooden_chest_small".into(),
11932 display_name: "Camp Chest".into(),
11933 x: 129.0,
11934 y: 128.0,
11935 z: 0.0,
11936 locked: false,
11937 accessible: true,
11938 owner_character_id: None,
11939 contents: Vec::new(),
11940 lock_id: Some(lock.clone()),
11941 capacity_volume: None,
11942 item_instance_id: None,
11943 tile_id: None,
11944 paperdoll_ref: None,
11945 worker_lodging_capacity: None,
11946 }];
11947 let key = flatland_protocol::ItemStack {
11948 template_id: KEY_TEMPLATE.into(),
11949 quantity: 1,
11950 item_instance_id: Some(key_id),
11951 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
11952 status_bindings: Vec::new(),
11953 contents: Vec::new(),
11954 display_name: None,
11955 category: Some("key".into()),
11956 base_mass: None,
11957 base_volume: None,
11958 capacity_volume: None,
11959 stackable: None,
11960 world_placeable: None,
11961 worker_lodging_capacity: None,
11962 equip_slot: None,
11963 armor_physical: None,
11964 resists: vec![],
11965 hand_slots: None,
11966 };
11967 state.inventory_stacks = vec![key.clone()];
11968 assert!(!state.key_drop_blocked(&key));
11969 let opts = state.move_destinations_for(
11970 &flatland_protocol::InventoryLocation::Root,
11971 None,
11972 Some(key_id),
11973 KEY_TEMPLATE,
11974 );
11975 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
11976 }
11977
11978 #[test]
11979 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
11980 use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
11981
11982 let mut state = sample_state();
11983 let curve = ProgressionCurve::default();
11984 let bootstrap = ProgressionXp::bootstrap_new(
11985 curve.baseline_display,
11986 curve.xp_base,
11987 curve.xp_growth,
11988 );
11989 let mut fresh = bootstrap.clone();
11990 fresh.strength += 0.08;
11991 if let Some(player) = state.player.as_mut() {
11992 player.progression_xp = Some(bootstrap);
11993 }
11994
11995 let combat = CombatHud {
11996 progression_xp: Some(fresh.clone()),
11997 progression_baseline: curve.baseline_display,
11998 progression_xp_base: curve.xp_base,
11999 progression_xp_growth: curve.xp_growth,
12000 attributes: state.player.as_ref().and_then(|p| p.attributes),
12001 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
12002 ..CombatHud::default()
12003 };
12004 state.apply_combat_hud(&combat);
12005
12006 let xp = state
12007 .player
12008 .as_ref()
12009 .and_then(|p| p.progression_xp.as_ref())
12010 .expect("xp");
12011 assert!((xp.strength - fresh.strength).abs() < 0.001);
12012 assert!(state.progression_curve.is_some());
12013 }
12014
12015 #[test]
12016 fn combat_hud_syncs_known_abilities_and_hotbar() {
12017 use flatland_protocol::CombatHud;
12018
12019 let mut state = sample_state();
12020 let combat = CombatHud {
12021 known_abilities: vec!["unarmed".into(), "fireball".into()],
12022 hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
12023 max_abilities_per_rotation: 4,
12024 ability_id: "short_sword_slash".into(),
12025 ..CombatHud::default()
12026 };
12027 state.apply_combat_hud(&combat);
12028
12029 assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
12030 assert_eq!(state.hotbar_ability(1), Some("fireball"));
12031 assert_eq!(state.hotbar_ability(2), None);
12032 assert_eq!(state.hotbar_ability(3), Some("unarmed"));
12033 assert_eq!(state.max_abilities_per_rotation, 4);
12034 let choices = state.loadout_ability_choices();
12035 assert!(choices.iter().any(|a| a == "short_sword_slash"));
12036 assert!(choices.iter().any(|a| a == "fireball"));
12037 }
12038
12039 #[test]
12040 fn loadout_hotbar_choices_include_inventory_consumables() {
12041 let mut state = sample_state();
12042 state.known_abilities = vec!["unarmed".into()];
12043 state.weapon_ability_id = "unarmed".into();
12044 state.inventory_stacks = vec![flatland_protocol::ItemStack {
12045 template_id: "bottle_of_water".into(),
12046 quantity: 3,
12047 item_instance_id: Some(uuid::Uuid::from_u128(9)),
12048 display_name: Some("Bottle of Water".into()),
12049 category: Some("consumable".into()),
12050 ..Default::default()
12051 }];
12052 state.inventory.insert("bottle_of_water".into(), 3);
12053 state.inventory_hints.insert(
12054 "bottle_of_water".into(),
12055 InventoryHint {
12056 display_name: "Bottle of Water".into(),
12057 category: "consumable".into(),
12058 ..Default::default()
12059 },
12060 );
12061
12062 let choices = state.loadout_hotbar_choices();
12063 assert!(choices.iter().any(|c| c.binding == "unarmed"));
12064 let water = choices
12065 .iter()
12066 .find(|c| c.binding == "item:bottle_of_water")
12067 .expect("water binding");
12068 assert_eq!(water.meta.as_deref(), Some("use"));
12069 assert!(water.label.contains("Water"));
12070 assert_eq!(
12071 state.hotbar_slot_label(1),
12072 None,
12073 "unbound until set"
12074 );
12075 state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
12076 assert_eq!(
12077 state.hotbar_slot_label(5).as_deref(),
12078 Some("Bottle of Water×3")
12079 );
12080 }
12081
12082 #[test]
12083 fn loose_consumable_move_picker_offers_use_and_storage() {
12084 let mut state = sample_state();
12085 let inst = uuid::Uuid::from_u128(77);
12086 state.inventory_stacks = vec![flatland_protocol::ItemStack {
12087 template_id: "carrot".into(),
12088 quantity: 2,
12089 item_instance_id: Some(inst),
12090 props: Default::default(),
12091 status_bindings: Vec::new(),
12092 contents: Vec::new(),
12093 display_name: Some("Wild Carrot".into()),
12094 category: Some("consumable".into()),
12095 base_mass: None,
12096 base_volume: None,
12097 capacity_volume: None,
12098 stackable: Some(true),
12099 world_placeable: None,
12100 worker_lodging_capacity: None,
12101 equip_slot: None,
12102 armor_physical: None,
12103 resists: vec![],
12104 hand_slots: None,
12105 }];
12106 state.inventory_hints.insert(
12107 "carrot".into(),
12108 InventoryHint {
12109 display_name: "Wild Carrot".into(),
12110 category: "consumable".into(),
12111 base_mass: Some(0.15),
12112 base_volume: Some(0.3),
12113 capacity_volume: None,
12114 stackable: true,
12115 },
12116 );
12117 state.show_inventory_menu = true;
12118 state.inventory_menu_index = 0;
12119
12120 let row = state.inventory_selected_row().expect("carrot row");
12121 let mut options = state.move_destinations_for(
12122 &row.from,
12123 row.from_parent_instance_id,
12124 row.stack.item_instance_id,
12125 &row.stack.template_id,
12126 );
12127 if row.from == flatland_protocol::InventoryLocation::Root
12128 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
12129 {
12130 options.insert(
12131 0,
12132 MoveOption {
12133 label: "Use (eat / drink)".into(),
12134 kind: MoveOptionKind::Use,
12135 },
12136 );
12137 }
12138
12139 assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
12140 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
12141 assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
12142 }
12143
12144 #[test]
12145 fn inventory_category_group_order_is_stable() {
12146 assert_eq!(inventory_category_group("weapon").0, "Weapons");
12147 assert_eq!(inventory_category_group("armor").0, "Armor");
12148 assert_eq!(inventory_category_group("consumable").0, "Consumables");
12149 assert_eq!(inventory_category_group("resource").0, "Resources");
12150 assert_eq!(inventory_category_group("container").0, "Containers");
12151 assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
12152 assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
12153 }
12154
12155 #[test]
12156 fn page_list_index_clamps_without_wrap() {
12157 assert_eq!(page_list_index(0, -1, 25), 0);
12158 assert_eq!(page_list_index(0, 1, 25), 10);
12159 assert_eq!(page_list_index(12, 1, 25), 22);
12160 assert_eq!(page_list_index(22, 1, 25), 24);
12161 assert_eq!(page_list_index(5, 1, 0), 0);
12162 assert_eq!(page_list_index(3, -1, 8), 0);
12163 }
12164
12165 #[test]
12166 fn inventory_filter_hides_non_matching_person_items() {
12167 let mut state = sample_state();
12168 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
12169 sword.display_name = Some("Iron Sword".into());
12170 sword.category = Some("weapon".into());
12171 let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
12172 herb.display_name = Some("Wild Herb".into());
12173 herb.category = Some("consumable".into());
12174 state.inventory_stacks = vec![sword, herb];
12175 state.inventory_tab = InventoryTab::OnPerson;
12176 state.inventory_filter = "sword".into();
12177
12178 let rows = state.inventory_selectable_rows();
12179 assert_eq!(rows.len(), 1);
12180 assert_eq!(rows[0].stack.template_id, "iron_sword");
12181
12182 let lines = state.inventory_browser_lines();
12183 assert!(lines.iter().any(|l| matches!(
12184 l,
12185 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
12186 )));
12187 assert!(!lines.iter().any(|l| matches!(
12188 l,
12189 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
12190 )));
12191 }
12192
12193 #[test]
12194 fn inventory_person_rows_group_by_category() {
12195 let mut state = sample_state();
12196 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
12197 sword.category = Some("weapon".into());
12198 sword.display_name = Some("Iron Sword".into());
12199 let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
12200 ore.category = Some("resource".into());
12201 ore.display_name = Some("Iron Ore".into());
12202 let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
12203 potion.category = Some("consumable".into());
12204 potion.display_name = Some("Health Potion".into());
12205 state.inventory_stacks = vec![ore, potion, sword];
12206 state.inventory_tab = InventoryTab::OnPerson;
12207
12208 let lines = state.inventory_browser_lines();
12209 let labels: Vec<&str> = lines
12210 .iter()
12211 .filter_map(|l| match l {
12212 InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
12213 _ => None,
12214 })
12215 .collect();
12216 assert!(
12217 labels.iter().any(|s| s.contains("Weapons")),
12218 "expected Weapons group: {labels:?}"
12219 );
12220 assert!(labels.iter().any(|s| s.contains("Consumables")));
12221 assert!(labels.iter().any(|s| s.contains("Resources")));
12222
12223 let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
12224 let consumable_pos = labels.iter().position(|s| s.contains("Consumables")).unwrap();
12225 let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
12226 assert!(weapon_pos < consumable_pos);
12227 assert!(consumable_pos < resource_pos);
12228 }
12229
12230 #[test]
12231 fn inventory_tab_cycle_resets_selection() {
12232 let mut state = sample_state();
12233 state.inventory_tab = InventoryTab::OnPerson;
12234 state.inventory_menu_index = 3;
12235 state.inventory_tab = state.inventory_tab.cycle(true);
12236 assert_eq!(state.inventory_tab, InventoryTab::Nearby);
12237 assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
12239 assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
12240 assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
12241 assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
12242 }
12243
12244 #[test]
12245 fn parse_bank_copper_amount_blank_and_zero_mean_all() {
12246 assert_eq!(parse_bank_copper_amount(""), Some(0));
12247 assert_eq!(parse_bank_copper_amount(" "), Some(0));
12248 assert_eq!(parse_bank_copper_amount("0"), Some(0));
12249 assert_eq!(parse_bank_copper_amount("250"), Some(250));
12250 assert_eq!(parse_bank_copper_amount("nope"), None);
12251 }
12252
12253 #[test]
12254 fn parse_storage_quantity_blank_and_zero_mean_all() {
12255 assert_eq!(parse_storage_quantity(""), Some(None));
12256 assert_eq!(parse_storage_quantity(" "), Some(None));
12257 assert_eq!(parse_storage_quantity("0"), Some(None));
12258 assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
12259 assert_eq!(parse_storage_quantity("nope"), None);
12260 }
12261
12262 #[test]
12263 fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
12264 assert!(worker_error_is_hud_noise("path stuck — repathing"));
12265 assert!(worker_error_is_hud_noise("path stuck — nudged clear, repathing"));
12266 assert!(worker_error_is_hud_noise("returned to lodging after path failures"));
12267 assert!(!worker_error_is_hud_noise(
12269 "path stuck — no lodging to reset to"
12270 ));
12271 }
12272}