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