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