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