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