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