1use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
2use std::time::{Duration, Instant};
3
4use flatland_protocol::{
5 AbilityCooldownHud, BlueprintView, BodySlot, BuildingView, CastProgressHud, CombatCueKind,
6 CombatFxHitOutcome, CombatFxKind, CombatHud, CombatSlotHud, CombatTargetHud, DoorView,
7 EntityId, EntityState, Intent, InteriorMapView, ItemCatalogEntryView, LifeState, NpcView,
8 RotationPreset, Seq, SessionId, TerrainKindView, TerrainZoneView, Tick, ZPlatformView,
9 ZTransitionView,
10};
11
12use crate::session::{PlayConnection, SessionEvent};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum CharacterSheetTab {
16 #[default]
17 Character,
18 Ledger,
19 Career,
20}
21
22impl CharacterSheetTab {
23 pub fn cycle(self) -> Self {
24 match self {
25 Self::Character => Self::Ledger,
26 Self::Ledger => Self::Career,
27 Self::Career => Self::Character,
28 }
29 }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum LedgerPeriod {
34 #[default]
35 Day,
36 Week,
37 Month,
38 Lifetime,
39}
40
41impl LedgerPeriod {
42 pub fn label(self) -> &'static str {
43 match self {
44 Self::Day => "Day",
45 Self::Week => "Week",
46 Self::Month => "Month",
47 Self::Lifetime => "All",
48 }
49 }
50
51 pub fn cycle(self) -> Self {
52 match self {
53 Self::Day => Self::Week,
54 Self::Week => Self::Month,
55 Self::Month => Self::Lifetime,
56 Self::Lifetime => Self::Day,
57 }
58 }
59
60 pub fn from_digit(c: char) -> Option<Self> {
61 match c {
62 '1' => Some(Self::Day),
63 '2' => Some(Self::Week),
64 '3' => Some(Self::Month),
65 '4' => Some(Self::Lifetime),
66 _ => None,
67 }
68 }
69}
70
71const KEY_TEMPLATE: &str = "container_key";
73const PROPERTY_DEED_TEMPLATE: &str = "property_deed";
74const PROP_LOCK_ID: &str = "lock_id";
75const PROP_OPENS_LOCK_ID: &str = "opens_lock_id";
76const PROP_OPENS_CONTAINER_NAME: &str = "opens_container_name";
77const PROP_CUSTOM_NAME: &str = "custom_name";
78const PROP_LOCKED: &str = "locked";
79
80#[derive(Debug, Clone, PartialEq)]
82pub struct ClaimModeState {
83 pub zone_id: String,
84 pub width_m: u32,
85 pub height_m: u32,
86 pub anchor_x: f32,
87 pub anchor_y: f32,
88}
89
90#[derive(Debug, Clone, PartialEq)]
92pub struct RelocateModeState {
93 pub container_id: String,
94 pub label: String,
95 pub cursor_x: f32,
96 pub cursor_y: f32,
97}
98
99fn stack_is_locked(stack: &flatland_protocol::ItemStack) -> bool {
100 stack
101 .props
102 .get(PROP_LOCKED)
103 .is_some_and(|v| v == "true" || v == "1")
104}
105
106const MAX_LOG_LINES: usize = 200;
107const MAX_SHOP_TRADE_LOG_LINES: usize = 40;
108const INTERACTION_RADIUS_M: f32 = 1.5;
109const DOOR_INTERACTION_RADIUS_M: f32 = 2.0;
110const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
111const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
112const CRAFT_STAMINA_COST: f32 = 3.0;
114const WORKER_STEP_HOLD: Duration = Duration::from_millis(1200);
116const WORKER_ERROR_HOLD: Duration = Duration::from_secs(45);
118const WORKER_HEALTH_RING_HOLD: Duration = Duration::from_secs(6);
120const WORKER_HIRE_PENDING_TIMEOUT: Duration = Duration::from_secs(15);
122
123#[derive(Debug, Clone, Default)]
125pub struct InventoryHint {
126 pub display_name: String,
127 pub category: String,
128 pub base_mass: Option<f32>,
129 pub base_volume: Option<f32>,
130 pub capacity_volume: Option<f32>,
131 pub stackable: bool,
132 pub listable: bool,
134 pub base_value_copper: Option<u32>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct LoadoutHotbarChoice {
141 pub binding: String,
143 pub label: String,
145 pub meta: Option<String>,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
151pub enum RotationEditorMode {
152 #[default]
153 List,
154 EditSequence,
155 PickAbility,
156 EditLabel,
157}
158
159#[derive(Debug, Clone, Default)]
161pub struct RotationEditorState {
162 pub mode: RotationEditorMode,
163 pub list_index: usize,
164 pub ability_index: usize,
165 pub picker_index: usize,
166 pub draft: Option<RotationPreset>,
167 pub label_buffer: String,
168}
169
170impl RotationEditorState {
171 pub fn reset(&mut self) {
172 *self = Self::default();
173 }
174}
175
176pub const CONTAINER_RANGE_M: f32 = 3.0;
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum InventorySection {
186 Worn,
188 Person,
190 Nearby,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
196pub enum InventoryTab {
197 #[default]
198 OnPerson,
199 Nearby,
200}
201
202impl InventoryTab {
203 pub fn label(self) -> &'static str {
204 match self {
205 Self::OnPerson => "On person",
206 Self::Nearby => "Nearby storage",
207 }
208 }
209
210 pub fn cycle(self, forward: bool) -> Self {
211 match (self, forward) {
212 (Self::OnPerson, true) | (Self::OnPerson, false) => Self::Nearby,
213 (Self::Nearby, true) | (Self::Nearby, false) => Self::OnPerson,
214 }
215 }
216}
217
218pub const LIST_PAGE_SIZE: usize = 10;
220
221pub fn list_label_matches(haystack: &str, filter: &str) -> bool {
223 if filter.is_empty() {
224 return true;
225 }
226 haystack
227 .to_ascii_lowercase()
228 .contains(&filter.to_ascii_lowercase())
229}
230
231pub fn is_list_filter_char(ch: char) -> bool {
234 match ch {
235 ' '..='~' => true,
236 c if c.is_alphanumeric() => true,
237 _ => false,
238 }
239}
240
241pub fn page_list_index(index: usize, pages: i32, len: usize) -> usize {
243 if len == 0 {
244 return 0;
245 }
246 let page = LIST_PAGE_SIZE as i32;
247 let next = index as i32 + pages * page;
248 next.clamp(0, (len as i32) - 1) as usize
249}
250
251pub fn step_filtered_index(
253 index: usize,
254 delta: i32,
255 len: usize,
256 pred: impl Fn(usize) -> bool,
257) -> usize {
258 if len == 0 {
259 return 0;
260 }
261 let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
262 if matching.is_empty() {
263 return index.min(len - 1);
264 }
265 let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
266 let next = (pos as i32 + delta).rem_euclid(matching.len() as i32) as usize;
267 matching[next]
268}
269
270pub fn page_filtered_index(
272 index: usize,
273 pages: i32,
274 len: usize,
275 pred: impl Fn(usize) -> bool,
276) -> usize {
277 if len == 0 {
278 return 0;
279 }
280 let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
281 if matching.is_empty() {
282 return index.min(len - 1);
283 }
284 let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
285 let next = page_list_index(pos, pages, matching.len());
286 matching[next]
287}
288
289pub fn inventory_category_group(category: &str) -> (&'static str, u8) {
291 match category {
292 "weapon" | "ammo" => ("Weapons", 0),
293 "armor" | "shield" | "offhand" => ("Armor", 1),
294 "consumable" | "liquid" | "bulk" => ("Consumables", 2),
295 "resource" | "harvest_node" | "seed" => ("Resources", 3),
296 "container" | "lodging" => ("Containers", 4),
297 "currency" | "key" => ("Currency & keys", 5),
298 "tool" | "misc" | "furniture" | "quest" | "document" => ("Gear & misc", 6),
299 _ => ("Other", 7),
300 }
301}
302
303pub fn category_default_listable(category: &str) -> bool {
305 !matches!(
306 category,
307 "currency" | "harvest_node" | "key" | "quest" | "document" | "lodging"
308 )
309}
310
311fn vessel_holds_category(stack: &flatland_protocol::ItemStack, category: Option<&str>) -> bool {
312 let cat = category.unwrap_or("");
313 if let Some(holds) = stack.props.get("serving_holds") {
314 return holds.split(',').any(|p| {
315 let p = p.trim();
316 p == cat
317 || (cat == "liquid" && p == "liquid")
318 || (cat == "bulk" && p == "bulk")
319 || (matches!(cat, "consumable") && p == "food")
320 });
321 }
322 match cat {
324 "bulk" => stack.props.get("bulk_vessel").is_some_and(|v| v == "1"),
325 "liquid" => stack.props.get("liquid_vessel").is_some_and(|v| v == "1"),
326 _ => false,
327 }
328}
329
330fn serving_capacity_of(stack: &flatland_protocol::ItemStack) -> u32 {
331 stack
332 .props
333 .get("serving_capacity")
334 .and_then(|s| s.parse().ok())
335 .unwrap_or(0)
336}
337
338fn payload_units_in_vessel(stack: &flatland_protocol::ItemStack) -> u32 {
339 stack.contents.iter().map(|c| c.quantity).sum()
340}
341
342fn is_serving_vessel_stack(stack: &flatland_protocol::ItemStack) -> bool {
343 stack.props.get("serving").is_some_and(|v| v == "1")
344 || stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
345 || stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
346 || stack.props.contains_key("serving_holds")
347 || stack.props.contains_key("serving_capacity")
348}
349
350fn vessel_free_room_for_payload(
351 stack: &flatland_protocol::ItemStack,
352 payload_id: &str,
353 payload_category: Option<&str>,
354) -> u32 {
355 if !is_serving_vessel_stack(stack) || !vessel_holds_category(stack, payload_category) {
356 return 0;
357 }
358 let primary = stack.contents.iter().find(|c| c.quantity > 0);
359 let compatible = primary.is_none_or(|c| c.template_id == payload_id);
360 if !compatible {
361 return 0;
362 }
363 let cap = serving_capacity_of(stack);
364 let used = payload_units_in_vessel(stack);
365 let per_shell = cap.saturating_sub(used);
366 if per_shell == 0 {
367 return 0;
368 }
369 let shells = if stack.contents.is_empty() {
371 stack.quantity.max(1)
372 } else {
373 1
374 };
375 per_shell.saturating_mul(shells)
376}
377
378fn drain_payload_from_stacks(
379 stacks: &mut [flatland_protocol::ItemStack],
380 template_id: &str,
381 remaining: &mut u32,
382) {
383 if *remaining == 0 {
384 return;
385 }
386 for stack in stacks.iter_mut() {
387 if *remaining == 0 {
388 return;
389 }
390 if stack.template_id == template_id && stack.quantity > 0 {
391 let take = (*remaining).min(stack.quantity);
392 stack.quantity -= take;
393 *remaining -= take;
394 }
395 drain_payload_from_stacks(&mut stack.contents, template_id, remaining);
396 stack.contents.retain(|c| c.quantity > 0);
398 }
399}
400
401fn vessel_room_for_payload_in_stacks(
402 stacks: &[flatland_protocol::ItemStack],
403 payload_id: &str,
404 payload_category: Option<&str>,
405) -> u32 {
406 let mut room = 0u32;
407 for stack in stacks {
408 room = room.saturating_add(vessel_free_room_for_payload(
409 stack,
410 payload_id,
411 payload_category,
412 ));
413 room = room.saturating_add(vessel_room_for_payload_in_stacks(
414 &stack.contents,
415 payload_id,
416 payload_category,
417 ));
418 }
419 room
420}
421
422#[derive(Debug, Clone)]
424pub struct CraftVesselLine {
425 pub label: String,
426 pub holds: String,
427 pub capacity: u32,
428 pub used: u32,
429 pub free: u32,
430 pub quantity: u32,
431 pub accepts_output: bool,
432 pub location: &'static str,
433}
434
435#[derive(Debug, Clone)]
437pub struct CraftVesselStatus {
438 pub needs_vessel: bool,
439 pub output_label: String,
440 pub need_units: u32,
441 pub free_after_inputs: u32,
442 pub ok: bool,
443 pub vessels: Vec<CraftVesselLine>,
444}
445
446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448pub enum CraftTab {
449 Ready,
450 Favorites,
451 Recent,
452 Tier(u32),
453}
454
455impl CraftTab {
456 pub fn label(self) -> String {
457 match self {
458 Self::Ready => "Ready".into(),
459 Self::Favorites => "★".into(),
460 Self::Recent => "Recent".into(),
461 Self::Tier(n) => format!("T{n}"),
462 }
463 }
464}
465
466pub fn npc_market_dump_unit_estimate_copper(base_value: u32) -> Option<u32> {
468 if base_value == 0 {
469 return None;
470 }
471 let unit = ((base_value as f32) * 0.5).floor() as u32;
472 if unit == 0 {
473 return None;
474 }
475 Some(((unit as u64).saturating_mul(9500) / 10_000).max(1) as u32)
476}
477
478fn parse_bank_copper_amount(input: &str) -> Option<u64> {
480 let s = input.trim();
481 if s.is_empty() {
482 return Some(0);
483 }
484 s.parse::<u64>().ok()
485}
486
487fn parse_storage_quantity(input: &str) -> Option<Option<u32>> {
489 let s = input.trim();
490 if s.is_empty() || s == "0" {
491 return Some(None);
492 }
493 let n = s.parse::<u32>().ok()?;
494 if n == 0 {
495 return Some(None);
496 }
497 Some(Some(n))
498}
499
500fn storage_stack_label(stack: &flatland_protocol::ItemStack) -> String {
501 let name = stack
502 .display_name
503 .as_deref()
504 .unwrap_or(stack.template_id.as_str());
505 if stack.quantity > 1 {
506 format!("{name} ×{}", stack.quantity)
507 } else {
508 name.to_string()
509 }
510}
511
512pub fn body_slot_label(slot: BodySlot) -> &'static str {
515 match slot {
516 BodySlot::Head => "Head",
517 BodySlot::Chest => "Chest",
518 BodySlot::Forearms => "Forearms",
519 BodySlot::Legs => "Legs",
520 BodySlot::Feet => "Feet",
521 BodySlot::Cloak => "Cloak",
522 BodySlot::Back => "Back",
523 BodySlot::Waist => "Waist",
524 BodySlot::Earrings => "Earrings",
525 BodySlot::Necklace => "Necklace",
526 BodySlot::Eyeglasses => "Eyeglasses",
527 BodySlot::RingLeft1 => "Ring L1",
528 BodySlot::RingLeft2 => "Ring L2",
529 BodySlot::RingRight1 => "Ring R1",
530 BodySlot::RingRight2 => "Ring R2",
531 }
532}
533
534fn grant_target_matches_mode(stack: &flatland_protocol::ItemStack, mode: &str) -> bool {
535 let cat = stack.category.as_deref().unwrap_or("");
536 match mode {
537 "while_equipped" => {
538 stack.equip_slot.is_some()
539 || cat == "weapon"
540 || cat == "shield"
541 || cat == "offhand"
542 || cat == "armor"
543 }
544 _ => cat == "weapon" || cat == "ammo" || stack.props.contains_key("weapon_ability_id"),
545 }
546}
547
548fn grant_tags_match(stack: &flatland_protocol::ItemStack, grant_tags: &[&str]) -> bool {
549 if grant_tags.is_empty() {
550 return true;
551 }
552 let target_tags: Vec<&str> = stack
553 .props
554 .get("allowed_enchant_tags")
555 .map(|s| {
556 s.split(',')
557 .map(str::trim)
558 .filter(|t| !t.is_empty())
559 .collect()
560 })
561 .unwrap_or_default();
562 if target_tags.is_empty() {
563 return true;
564 }
565 grant_tags.iter().any(|t| target_tags.contains(t))
566}
567
568pub const DEFAULT_TICK_HZ: u32 = 30;
570
571pub fn format_binding_ttl(
573 binding: &flatland_protocol::ItemStatusBinding,
574 tick: u64,
575 tick_hz: u32,
576) -> String {
577 let Some(expires) = binding.expires_at_tick else {
578 return "permanent".into();
579 };
580 let hz = tick_hz.max(1) as f32;
581 let remaining = expires.saturating_sub(tick) as f32 / hz;
582 if remaining <= 0.0 {
583 return "expired".into();
584 }
585 if remaining >= 120.0 {
586 format!("{:.0}m left", remaining / 60.0)
587 } else if remaining >= 10.0 {
588 format!("{remaining:.0}s left")
589 } else {
590 format!("{remaining:.1}s left")
591 }
592}
593
594pub fn format_binding_mode(mode: flatland_protocol::ItemStatusBindingMode) -> &'static str {
595 match mode {
596 flatland_protocol::ItemStatusBindingMode::OnHit => "on hit",
597 flatland_protocol::ItemStatusBindingMode::WhileEquipped => "while equipped",
598 }
599}
600
601pub fn format_status_bindings_suffix(
603 bindings: &[flatland_protocol::ItemStatusBinding],
604 tick: u64,
605 tick_hz: u32,
606) -> String {
607 if bindings.is_empty() {
608 return String::new();
609 }
610 let parts: Vec<String> = bindings
611 .iter()
612 .map(|b| {
613 format!(
614 "{} ({}, {})",
615 b.effect_id,
616 format_binding_mode(b.mode),
617 format_binding_ttl(b, tick, tick_hz)
618 )
619 })
620 .collect();
621 format!(" · {}", parts.join("; "))
622}
623
624#[derive(Debug, Clone, Copy, PartialEq, Eq)]
625pub enum EquipPaperdollRow {
626 Body { slot: BodySlot, filled: bool },
627 Mainhand { filled: bool },
628 Offhand { filled: bool, locked: bool },
629}
630
631pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
632 let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
633 .iter()
634 .map(|slot| EquipPaperdollRow::Body {
635 slot: *slot,
636 filled: state.worn.contains_key(slot),
637 })
638 .collect();
639 let two_hand = state.mainhand_hand_slots >= 2;
640 rows.push(EquipPaperdollRow::Mainhand {
641 filled: state.mainhand_template_id.is_some(),
642 });
643 rows.push(EquipPaperdollRow::Offhand {
644 filled: state.offhand_template_id.is_some(),
645 locked: two_hand,
646 });
647 rows
648}
649
650fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
651 for stack in &state.inventory_stacks {
652 let matches = stack
653 .equip_slot
654 .map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
655 .unwrap_or(false)
656 || guess_body_slot(&stack.template_id) == Some(slot);
657 if matches {
658 return stack.item_instance_id;
659 }
660 }
661 None
662}
663
664fn is_client_ring(slot: BodySlot) -> bool {
665 matches!(
666 slot,
667 BodySlot::RingLeft1 | BodySlot::RingLeft2 | BodySlot::RingRight1 | BodySlot::RingRight2
668 )
669}
670
671fn first_inventory_weapon(state: &GameState) -> Option<String> {
672 for stack in &state.inventory_stacks {
673 if stack.category.as_deref() == Some("weapon") {
674 return Some(stack.template_id.clone());
675 }
676 }
677 None
678}
679
680fn first_inventory_offhand(state: &GameState) -> Option<String> {
681 for stack in &state.inventory_stacks {
682 let cat = stack.category.as_deref().unwrap_or("");
683 if matches!(cat, "shield" | "offhand") {
684 return Some(stack.template_id.clone());
685 }
686 }
687 None
688}
689
690fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
693 if template_id.contains("backpack") {
694 Some(BodySlot::Back)
695 } else if template_id.contains("belt") {
696 Some(BodySlot::Waist)
697 } else if template_id.contains("cloak") || template_id.contains("cape") {
698 Some(BodySlot::Cloak)
699 } else if template_id.contains("cap")
700 || template_id.contains("hat")
701 || template_id.contains("helm")
702 {
703 Some(BodySlot::Head)
704 } else if template_id.contains("shirt")
705 || template_id.contains("robe")
706 || template_id.contains("vest")
707 || template_id.contains("chest")
708 || template_id.contains("jerkin")
709 {
710 Some(BodySlot::Chest)
711 } else if template_id.contains("sleeves")
712 || template_id.contains("gloves")
713 || template_id.contains("gauntlets")
714 {
715 Some(BodySlot::Forearms)
716 } else if template_id.contains("pants") || template_id.contains("leggings") {
717 Some(BodySlot::Legs)
718 } else if template_id.contains("boots") || template_id.contains("shoes") {
719 Some(BodySlot::Feet)
720 } else if template_id.contains("earring") {
721 Some(BodySlot::Earrings)
722 } else if template_id.contains("necklace") || template_id.contains("amulet") {
723 Some(BodySlot::Necklace)
724 } else if template_id.contains("glass")
725 || template_id.contains("spectacles")
726 || template_id.contains("goggles")
727 {
728 Some(BodySlot::Eyeglasses)
729 } else if template_id.contains("ring") {
730 Some(BodySlot::RingLeft1)
731 } else {
732 None
733 }
734}
735
736#[derive(Debug, Clone)]
738pub struct InventoryRow {
739 pub depth: usize,
740 pub stack: flatland_protocol::ItemStack,
741 pub from: flatland_protocol::InventoryLocation,
743 pub from_parent_instance_id: Option<uuid::Uuid>,
745 pub is_equip_shell: bool,
747 pub is_chest_shell: bool,
749 pub section: InventorySection,
750}
751
752#[derive(Debug, Clone)]
754pub struct InventoryRowView {
755 pub depth: usize,
756 pub text: String,
758 pub title: String,
760 pub mass_kg: Option<f32>,
761 pub volume: Option<(f32, f32)>,
762 pub instance_tooltip: Option<String>,
764}
765
766#[derive(Debug, Clone)]
768pub enum InventoryBrowserLine {
769 Section(String),
770 SlotLabel(String),
771 Hint(String),
772 Blank,
773 Item {
774 selectable_index: usize,
775 selected: bool,
776 depth: usize,
777 text: String,
778 title: String,
779 mass_kg: Option<f32>,
780 volume: Option<(f32, f32)>,
781 instance_tooltip: Option<String>,
782 },
783}
784
785#[derive(Debug, Clone, PartialEq, Eq, Default)]
787pub enum BankUiMode {
788 #[default]
789 Menu,
790 DepositAmount {
791 input: String,
792 },
793 WithdrawAmount {
794 input: String,
795 },
796 TransferName {
797 input: String,
798 },
799 TransferAmount {
800 to_name: String,
801 input: String,
802 },
803}
804
805#[derive(Debug, Clone, PartialEq, Eq, Default)]
807pub enum StorageUiMode {
808 #[default]
809 Menu,
810 StorePick { index: usize },
812 StoreAmount {
814 pick_index: usize,
815 item_instance_id: uuid::Uuid,
816 label: String,
817 max_qty: u32,
818 input: String,
819 },
820 TakePick { index: usize },
822 TakeAmount {
824 pick_index: usize,
825 item_instance_id: uuid::Uuid,
826 label: String,
827 max_qty: u32,
828 input: String,
829 },
830 ShipPick {
832 dest_building_id: String,
833 dest_label: String,
834 index: usize,
835 },
836 ShipAmount {
838 dest_building_id: String,
839 dest_label: String,
840 pick_index: usize,
841 item_instance_id: uuid::Uuid,
842 label: String,
843 max_qty: u32,
844 input: String,
845 },
846}
847
848#[derive(Debug, Clone, PartialEq, Eq)]
850pub enum MarketListSourceKind {
851 Person,
852 TownStorage { building_id: String },
853}
854
855#[derive(Debug, Clone, PartialEq, Eq, Default)]
857pub enum MarketUiMode {
858 #[default]
859 Browse,
860 ListSource { index: usize },
862 ListPick {
864 source: MarketListSourceKind,
865 index: usize,
866 },
867 ListAmount {
869 source: MarketListSourceKind,
870 pick_index: usize,
871 item_instance_id: uuid::Uuid,
872 template_id: String,
873 label: String,
874 max_qty: u32,
875 input: String,
876 },
877 ListPricingMode {
879 source: MarketListSourceKind,
880 pick_index: usize,
881 item_instance_id: uuid::Uuid,
882 template_id: String,
883 label: String,
884 quantity: Option<u32>,
885 max_qty: u32,
886 index: usize,
888 },
889 ListPrice {
891 source: MarketListSourceKind,
892 pick_index: usize,
893 item_instance_id: uuid::Uuid,
894 template_id: String,
895 label: String,
896 quantity: Option<u32>,
898 max_qty: u32,
899 input: String,
900 },
901}
902
903#[derive(Debug, Clone)]
905pub struct StoragePickOption {
906 pub item_instance_id: uuid::Uuid,
907 pub template_id: String,
908 pub label: String,
909 pub quantity: u32,
910 pub category: String,
912}
913
914#[derive(Debug, Clone)]
917pub struct NearbyContainer {
918 pub view: flatland_protocol::PlacedContainerView,
919 pub distance_m: f32,
920 pub rows: Vec<InventoryRow>,
921}
922
923#[derive(Debug, Clone)]
925pub struct KeychainEntry {
926 pub stack: flatland_protocol::ItemStack,
927 pub stowed: bool,
928}
929
930#[derive(Debug, Clone)]
932pub struct MoveOption {
933 pub label: String,
934 pub kind: MoveOptionKind,
935}
936
937#[derive(Debug, Clone, PartialEq)]
938pub enum MoveOptionKind {
939 Move {
940 location: flatland_protocol::InventoryLocation,
941 parent_instance_id: Option<uuid::Uuid>,
942 },
943 PickupPlaced {
945 container_id: String,
946 nest_location: flatland_protocol::InventoryLocation,
947 nest_parent_instance_id: Option<uuid::Uuid>,
948 },
949 RelocatePlaced {
951 container_id: String,
952 },
953 Use,
955 GrantApply,
957 Drop,
958 SellPlotToCrown {
960 plot_id: uuid::Uuid,
961 },
962 Cancel,
963}
964
965#[derive(Debug, Clone, PartialEq)]
967pub enum FarmAccessRow {
968 PublicToggle,
969 PublicDiscount,
970 AllowRemove {
971 character_id: uuid::Uuid,
972 label: String,
973 tax_discount_bps: u32,
974 },
975 NearbyAdd {
976 name: String,
977 },
978}
979
980#[derive(Debug, Clone)]
982pub struct GrantTargetPicker {
983 pub grant_instance_id: uuid::Uuid,
984 pub grant_label: String,
985 pub effect_id: String,
986 pub mode: String,
987 pub options: Vec<GrantTargetOption>,
988 pub filter: String,
989 pub filter_focused: bool,
990}
991
992#[derive(Debug, Clone)]
993pub struct GrantTargetOption {
994 pub label: String,
995 pub target_instance_id: uuid::Uuid,
996}
997
998#[derive(Debug, Clone)]
1000pub struct MovePicker {
1001 pub item_instance_id: uuid::Uuid,
1002 pub from: flatland_protocol::InventoryLocation,
1003 pub item_label: String,
1004 pub template_id: String,
1005 pub stack_quantity: u32,
1006 pub quantity: u32,
1007 pub options: Vec<MoveOption>,
1008 pub filter: String,
1009 pub filter_focused: bool,
1010}
1011
1012#[derive(Debug, Clone)]
1014pub struct DestroyPicker {
1015 pub item_instance_id: uuid::Uuid,
1016 pub from: flatland_protocol::InventoryLocation,
1017 pub item_label: String,
1018 pub stack_quantity: u32,
1019 pub quantity: u32,
1020}
1021
1022#[derive(Debug, Clone)]
1024pub struct WorkerGiveOption {
1025 pub item_instance_id: uuid::Uuid,
1026 pub label: String,
1027 pub quantity: u32,
1028 pub template_id: String,
1029}
1030
1031#[derive(Debug, Clone)]
1033pub struct WorkerGivePicker {
1034 pub worker_instance_id: String,
1035 pub worker_label: String,
1036 pub options: Vec<WorkerGiveOption>,
1037}
1038
1039#[derive(Debug, Clone)]
1041pub struct WorkerGiveTargetOption {
1042 pub instance_id: String,
1043 pub label: String,
1044 pub distance_m: f32,
1045}
1046
1047#[derive(Debug, Clone)]
1049pub struct WorkerGiveTargetPicker {
1050 pub item_instance_id: uuid::Uuid,
1051 pub item_label: String,
1052 pub quantity: Option<u32>,
1053 pub options: Vec<WorkerGiveTargetOption>,
1054}
1055
1056#[derive(Debug, Clone)]
1058pub struct WorkerTakePicker {
1059 pub worker_instance_id: String,
1060 pub worker_label: String,
1061 pub options: Vec<WorkerGiveOption>,
1062 pub quantity: u32,
1064}
1065
1066pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
1068
1069#[derive(Debug, Clone)]
1071pub struct WorkerTeachOption {
1072 pub blueprint_id: String,
1073 pub label: String,
1074 pub cost_copper: u64,
1075 pub min_level: u32,
1076 pub worker_level: u32,
1077 pub can_afford: bool,
1078 pub level_ok: bool,
1079}
1080
1081#[derive(Debug, Clone)]
1083pub struct WorkerTeachPicker {
1084 pub worker_instance_id: String,
1085 pub worker_label: String,
1086 pub worker_level: u32,
1087 pub options: Vec<WorkerTeachOption>,
1088}
1089
1090#[derive(Debug, Clone)]
1092pub struct WorkerDismissConfirmation {
1093 pub worker_instance_id: String,
1094 pub worker_label: String,
1095}
1096
1097#[derive(Debug, Clone, Default)]
1100pub struct StickyWorkerStep {
1101 shown: String,
1102 pending: String,
1103 pending_since: Option<Instant>,
1104}
1105
1106impl StickyWorkerStep {
1107 fn from_label(label: String) -> Self {
1108 Self {
1109 shown: label.clone(),
1110 pending: label,
1111 pending_since: Some(Instant::now()),
1112 }
1113 }
1114
1115 fn observe(&mut self, label: &str, now: Instant) {
1116 let pending_since = self.pending_since.unwrap_or(now);
1117 if label == self.pending {
1118 if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD {
1119 self.shown = self.pending.clone();
1120 }
1121 return;
1122 }
1123 self.pending = label.to_string();
1124 self.pending_since = Some(now);
1125 if self.shown.is_empty() {
1127 self.shown = self.pending.clone();
1128 }
1129 }
1130}
1131
1132#[derive(Debug, Clone, Default)]
1135pub struct StickyWorkerError {
1136 message: String,
1137 last_seen: Option<Instant>,
1138}
1139
1140impl StickyWorkerError {
1141 fn observe(&mut self, err: Option<&str>, now: Instant) {
1142 if let Some(e) = err {
1143 if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
1144 self.message = e.to_string();
1145 self.last_seen = Some(now);
1146 }
1147 return;
1148 }
1149 if let Some(seen) = self.last_seen {
1150 if now.duration_since(seen) > WORKER_ERROR_HOLD {
1151 self.message.clear();
1152 self.last_seen = None;
1153 }
1154 }
1155 }
1156
1157 pub fn shown(&self, now: Instant) -> Option<&str> {
1158 if self.message.is_empty() {
1159 return None;
1160 }
1161 let seen = self.last_seen?;
1162 if now.duration_since(seen) > WORKER_ERROR_HOLD {
1163 return None;
1164 }
1165 Some(self.message.as_str())
1166 }
1167}
1168
1169pub fn worker_attention_line(state: &GameState) -> Option<String> {
1172 use flatland_protocol::WorkerStateView;
1173 let now = Instant::now();
1174 for w in &state.hired_workers {
1175 if matches!(w.state, WorkerStateView::Strike) {
1176 return Some(format!(
1177 "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
1178 w.label
1179 ));
1180 }
1181 let sticky = state
1182 .worker_error_display
1183 .get(&w.instance_id)
1184 .and_then(|s| s.shown(now))
1185 .filter(|e| !worker_error_is_hud_noise(e));
1186 let live = w
1187 .last_error
1188 .as_deref()
1189 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e));
1190 if let Some(err) = sticky.or(live) {
1191 if let Some(hint) = w
1192 .issue_hint
1193 .as_deref()
1194 .filter(|h| !h.is_empty())
1195 .or_else(|| worker_issue_fix_hint(err))
1196 {
1197 return Some(format!("Worker {}: {err} — {hint}", w.label));
1198 }
1199 return Some(format!("Worker {}: {err}", w.label));
1200 }
1201 if let Some(hint) = w.issue_hint.as_deref().filter(|h| !h.is_empty()) {
1203 return Some(format!("Worker {}: {hint}", w.label));
1204 }
1205 }
1206 None
1207}
1208
1209pub fn worker_issue_fix_hint(err: &str) -> Option<&'static str> {
1211 let e = err.to_ascii_lowercase();
1212 if e.contains("missing")
1213 || e.contains("container not found")
1214 || e.contains("lodging container not found")
1215 {
1216 return Some("edit route (e): replace the missing chest/bed");
1217 }
1218 if e.contains("stranded at interior") || e.contains("interior map coords") {
1219 return Some("recovered — continuing route");
1220 }
1221 if e.contains("stuck inside")
1222 || e.contains("sent outside")
1223 || e.contains("sent to door")
1224 || e.contains("left building")
1225 {
1226 return Some("auto-exit for outdoor work — restart after update if it still loops");
1227 }
1228 if e.contains("collapsed") || e.contains("need food") {
1229 return Some("stock lodging bed with food and drink");
1230 }
1231 if e.contains("overburdened") {
1232 return Some("add a deposit/sell stop, or empty their pack");
1233 }
1234 if e.contains("need a hoe") || e.contains("need a dibber") {
1235 return Some("give them the tool or withdraw it on the route");
1236 }
1237 None
1238}
1239
1240pub fn worker_error_is_transient(err: &str) -> bool {
1242 let e = err.to_ascii_lowercase();
1243 e.contains("continuing route")
1244 || e.contains("storage full")
1245 || e.starts_with("nothing to withdraw")
1246}
1247
1248pub fn worker_error_is_hud_noise(err: &str) -> bool {
1251 let e = err.to_ascii_lowercase();
1252 if e.contains("idling") && (e.contains("cannot reach") || e.contains("unreachable")) {
1254 return false;
1255 }
1256 e.contains("returned to lodging after path")
1257 || e.contains("path failure")
1258 || e.contains("no path to")
1259 || e.contains("pathfinding")
1260 || e.contains("repathing")
1262 || e.contains("nudged clear")
1263 || e.contains("auto-recovery")
1265 || e.contains("stranded at interior map coords")
1266}
1267
1268#[derive(Debug, Clone)]
1270pub struct PendingWorkerJobAck {
1271 pub seq: u32,
1272 pub worker_instance_id: String,
1273 pub worker_label: String,
1274 pub idle: bool,
1275 pub stop_count: usize,
1276 pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1277 pub prev_mode: flatland_protocol::WorkerModeView,
1278 pub prev_step_label: String,
1279 pub prev_last_error: Option<String>,
1280}
1281
1282fn push_inventory_rows(
1283 rows: &mut Vec<InventoryRow>,
1284 depth: usize,
1285 stack: &flatland_protocol::ItemStack,
1286 from: &flatland_protocol::InventoryLocation,
1287 from_parent_instance_id: Option<uuid::Uuid>,
1288 section: InventorySection,
1289) {
1290 push_inventory_rows_filtered(
1291 rows,
1292 depth,
1293 stack,
1294 from,
1295 from_parent_instance_id,
1296 section,
1297 "",
1298 );
1299}
1300
1301fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1302 if filter.is_empty() {
1303 return true;
1304 }
1305 let f = filter.to_ascii_lowercase();
1306 let name = stack
1307 .display_name
1308 .as_deref()
1309 .unwrap_or("")
1310 .to_ascii_lowercase();
1311 let tid = stack.template_id.to_ascii_lowercase();
1312 name.contains(&f)
1313 || tid.contains(&f)
1314 || stack
1315 .contents
1316 .iter()
1317 .any(|c| stack_matches_filter(c, filter))
1318}
1319
1320fn push_inventory_rows_filtered(
1321 rows: &mut Vec<InventoryRow>,
1322 depth: usize,
1323 stack: &flatland_protocol::ItemStack,
1324 from: &flatland_protocol::InventoryLocation,
1325 from_parent_instance_id: Option<uuid::Uuid>,
1326 section: InventorySection,
1327 filter: &str,
1328) {
1329 if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1330 return;
1331 }
1332 let self_hit = filter.is_empty() || {
1333 let f = filter.to_ascii_lowercase();
1334 let name = stack
1335 .display_name
1336 .as_deref()
1337 .unwrap_or("")
1338 .to_ascii_lowercase();
1339 let tid = stack.template_id.to_ascii_lowercase();
1340 name.contains(&f) || tid.contains(&f)
1341 };
1342 rows.push(InventoryRow {
1343 depth,
1344 stack: stack.clone(),
1345 from: from.clone(),
1346 from_parent_instance_id,
1347 is_equip_shell: false,
1348 is_chest_shell: false,
1349 section,
1350 });
1351 for child in &stack.contents {
1352 if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1353 push_inventory_rows_filtered(
1354 rows,
1355 depth + 1,
1356 child,
1357 from,
1358 stack.item_instance_id,
1359 section,
1360 if self_hit { "" } else { filter },
1361 );
1362 }
1363 }
1364}
1365
1366#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1367pub enum ShopTab {
1368 #[default]
1369 Buy,
1370 Sell,
1371}
1372
1373#[derive(Debug, Clone)]
1374pub struct NpcChatState {
1375 pub npc_id: String,
1376 pub npc_label: String,
1377 pub lines: Vec<String>,
1378 pub input: String,
1379 pub pending: bool,
1380 pub talk_depth: flatland_protocol::NpcTalkDepth,
1381 pub trade_allowed: bool,
1382 pub banner: Option<String>,
1383 pub suggested_topics: Vec<String>,
1384}
1385
1386impl Default for NpcChatState {
1387 fn default() -> Self {
1388 Self {
1389 npc_id: String::new(),
1390 npc_label: String::new(),
1391 lines: Vec::new(),
1392 input: String::new(),
1393 pending: false,
1394 talk_depth: flatland_protocol::NpcTalkDepth::Full,
1395 trade_allowed: true,
1396 banner: None,
1397 suggested_topics: Vec::new(),
1398 }
1399 }
1400}
1401
1402pub fn npc_world_xy(state: &GameState, npc: &NpcView) -> (f32, f32) {
1404 npc.entity_id
1405 .and_then(|eid| state.entities.iter().find(|e| e.id == eid))
1406 .map(|e| (e.transform.position.x, e.transform.position.y))
1407 .unwrap_or((npc.x, npc.y))
1408}
1409
1410#[derive(Debug, Clone)]
1411pub struct GameState {
1412 pub session_id: SessionId,
1413 pub entity_id: EntityId,
1414 pub character_id: Option<uuid::Uuid>,
1416 pub tick: Tick,
1417 pub chunk_rev: u64,
1418 pub content_rev: u64,
1419 pub publish_rev: u64,
1420 pub entities: Vec<EntityState>,
1421 pub player: Option<EntityState>,
1422 pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1423 pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1424 pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1425 pub buildings: Vec<BuildingView>,
1426 pub doors: Vec<DoorView>,
1427 pub interior_map: Option<InteriorMapView>,
1428 pub npcs: Vec<NpcView>,
1429 pub blueprints: Vec<BlueprintView>,
1430 pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1432 pub world_x0: f32,
1434 pub world_y0: f32,
1435 pub world_width_m: f32,
1436 pub world_height_m: f32,
1437 pub terrain_zones: Vec<TerrainZoneView>,
1438 pub z_platforms: Vec<ZPlatformView>,
1439 pub z_transitions: Vec<ZTransitionView>,
1440 #[doc(hidden)]
1443 pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1444 pub world_clock: flatland_protocol::WorldClock,
1445 pub inventory: std::collections::HashMap<String, u32>,
1446 pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1447 pub item_catalog: std::collections::HashMap<String, ItemCatalogEntryView>,
1449 pub logs: VecDeque<String>,
1450 pub intents_sent: u64,
1451 pub ticks_received: u64,
1452 pub connected: bool,
1453 pub disconnect_reason: Option<String>,
1454 pub show_stats: bool,
1455 pub hud_log_hidden: bool,
1457 pub show_equip_menu: bool,
1458 pub equip_menu_index: usize,
1459 pub show_craft_menu: bool,
1460 pub craft_menu_index: usize,
1461 pub craft_batch_quantity: u32,
1463 pub craft_tab: CraftTab,
1465 pub craft_filter: String,
1467 pub craft_filter_focused: bool,
1468 pub craft_prefs: crate::craft_prefs::CraftCharacterPrefs,
1470 pub show_plot_build_menu: bool,
1472 pub plot_build_focus_wall: bool,
1474 pub plot_build_wall_index: usize,
1475 pub plot_build_roof_index: usize,
1476 pub show_shop_menu: bool,
1477 pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1478 pub bank_panel: Option<flatland_protocol::BankPanel>,
1479 pub bank_menu_index: usize,
1480 pub bank_ui_mode: BankUiMode,
1481 pub storage_panel: Option<flatland_protocol::StoragePanel>,
1482 pub market_panel: Option<flatland_protocol::MarketPanel>,
1483 pub market_menu_index: usize,
1485 pub market_filter: String,
1487 pub market_filter_focused: bool,
1488 pub market_category_filter: Option<&'static str>,
1490 pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1492 pub market_ui_mode: MarketUiMode,
1493 pub storage_menu_index: usize,
1494 pub storage_ui_mode: StorageUiMode,
1495 pub shop_tab: ShopTab,
1496 pub shop_menu_index: usize,
1497 pub shop_quantity: u32,
1498 pub shop_trade_log: VecDeque<String>,
1500 pub show_npc_verb_menu: bool,
1501 pub npc_verb_target: Option<String>,
1502 pub npc_verb_index: usize,
1503 pub npc_verb_notice: Option<String>,
1505 pub player_verbs: crate::social::PlayerVerbState,
1507 pub social_chat: crate::social::SocialChatState,
1508 pub trade_ui: crate::social::TradeUiState,
1509 pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1510 pub show_npc_chat: bool,
1511 pub npc_chat: Option<NpcChatState>,
1512 pub show_inventory_menu: bool,
1513 pub inventory_menu_index: usize,
1514 pub inventory_tab: InventoryTab,
1515 pub inventory_filter: String,
1516 pub inventory_filter_focused: bool,
1517 pub show_move_picker: bool,
1518 pub move_picker_index: usize,
1519 pub move_picker: Option<MovePicker>,
1520 pub show_grant_picker: bool,
1521 pub grant_picker_index: usize,
1522 pub grant_picker: Option<GrantTargetPicker>,
1523 pub show_destroy_picker: bool,
1524 pub destroy_confirm_pending: bool,
1525 pub destroy_picker: Option<DestroyPicker>,
1526 pub show_rename_prompt: bool,
1528 pub rename_plot_id: Option<uuid::Uuid>,
1530 pub highlighted_plot_id: Option<uuid::Uuid>,
1532 pub show_worker_rename: bool,
1534 pub rename_buffer: String,
1535 pub combat_target: Option<EntityId>,
1537 pub combat_target_label: Option<String>,
1538 pub ground_target: Option<(f32, f32, f32)>,
1541 pub combat_fx: Vec<flatland_protocol::CombatFx>,
1543 pub ground_hazards: Vec<flatland_protocol::GroundHazardView>,
1545 pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1547 pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1549 pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1551 pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1553 pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1555 pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1557 pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1559 pub claim_mode: Option<ClaimModeState>,
1561 pub relocate_mode: Option<RelocateModeState>,
1563 pub sell_plot_confirm: Option<uuid::Uuid>,
1565 pub sell_plot_armed_at: Option<Instant>,
1567 pub show_plant_menu: bool,
1569 pub plant_menu_index: usize,
1570 pub show_farm_access: bool,
1572 pub farm_access_name_draft: String,
1574 pub farm_access_discount_bps: u32,
1576 pub farm_access_index: usize,
1578 pub plant_quantity: u32,
1579 pub in_combat: bool,
1580 pub auto_attack: bool,
1581 pub combat_has_los: bool,
1582 pub attack_cd_ticks: u64,
1583 pub gcd_ticks: u64,
1584 pub weapon_ability_id: String,
1585 pub mainhand_template_id: Option<String>,
1586 pub mainhand_label: Option<String>,
1587 pub mainhand_instance_id: Option<uuid::Uuid>,
1588 pub offhand_template_id: Option<String>,
1589 pub offhand_label: Option<String>,
1590 pub offhand_instance_id: Option<uuid::Uuid>,
1591 pub mainhand_hand_slots: u8,
1592 pub defense: Option<flatland_protocol::DefenseHud>,
1593 pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1595 pub carry_mass: f32,
1596 pub carry_mass_max: f32,
1597 pub encumbrance: flatland_protocol::EncumbranceState,
1598 pub move_speed_mps: f32,
1600 pub move_speed_mult: f32,
1602 pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1604 pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1606 pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1608 pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1610 pub combat_target_detail: Option<CombatTargetHud>,
1611 pub cast_progress: Option<CastProgressHud>,
1612 pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1614 pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1616 pub ability_cooldowns: Vec<AbilityCooldownHud>,
1617 pub blocking_active: bool,
1618 pub max_target_slots: u8,
1619 pub combat_slots: Vec<CombatSlotHud>,
1620 pub rotation_presets: Vec<RotationPreset>,
1621 pub known_abilities: Vec<String>,
1623 pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1625 pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1627 pub hotbar: Vec<Option<String>>,
1629 pub max_abilities_per_rotation: u8,
1631 pub show_loadout_menu: bool,
1632 pub show_keychain_menu: bool,
1633 pub keychain_menu_index: usize,
1634 pub show_rotation_editor: bool,
1635 pub loadout_menu_index: usize,
1637 pub loadout_hotbar_slot: u8,
1639 pub loadout_ability_index: usize,
1641 pub loadout_focus_presets: bool,
1643 pub rotation_editor: RotationEditorState,
1644 pub harvest_in_progress: bool,
1646 pub harvest_started_at: Option<Instant>,
1648 pub pending_craft_ack: Option<(u32, String, u32)>,
1650 pub craft_channel_blueprint_id: Option<String>,
1653 pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1654 pub interactables: Vec<flatland_protocol::InteractableView>,
1655 pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1656 pub career: Option<flatland_protocol::PlayerCareerView>,
1657 pub character_sheet_tab: CharacterSheetTab,
1658 pub ledger_period: LedgerPeriod,
1659 pub show_quest_offer: bool,
1660 pub pending_quest_offers: Vec<flatland_protocol::QuestOffer>,
1661 pub quest_offer_index: usize,
1662 pub show_quest_menu: bool,
1663 pub quest_menu_index: usize,
1664 pub quest_withdraw_confirm: bool,
1665 pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1666 pub show_workers_menu: bool,
1667 pub workers_menu_index: usize,
1668 pub worker_dismiss_confirmation: Option<WorkerDismissConfirmation>,
1669 pub workers_menu_compact: bool,
1671 pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1674 pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1676 pub worker_health_ring_until: BTreeMap<EntityId, Instant>,
1678 pub pending_worker_hire_since: Option<Instant>,
1680 pub show_worker_give_picker: bool,
1682 pub worker_give_picker_index: usize,
1683 pub worker_give_picker: Option<WorkerGivePicker>,
1684 pub show_worker_give_target_picker: bool,
1686 pub worker_give_target_picker_index: usize,
1687 pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1688 pub show_worker_take_picker: bool,
1690 pub worker_take_picker_index: usize,
1691 pub worker_take_picker: Option<WorkerTakePicker>,
1692 pub show_worker_teach_picker: bool,
1694 pub worker_teach_picker_index: usize,
1695 pub worker_teach_picker: Option<WorkerTeachPicker>,
1696 pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1698 pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1700 pub attending_worker_instance_id: Option<String>,
1702 pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1704}
1705
1706#[derive(Debug, Clone, PartialEq, Eq)]
1707pub enum NpcVerbAction {
1708 Talk,
1709 Trade,
1710 Bank,
1711 Storage,
1712 Market,
1713 QuestTalk { quest_id: String },
1714 QuestGive { quest_id: String },
1715}
1716
1717#[derive(Debug, Clone, PartialEq, Eq)]
1718pub struct NpcVerbChoice {
1719 pub label: String,
1720 pub action: NpcVerbAction,
1721}
1722
1723impl std::fmt::Display for NpcVerbChoice {
1724 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1725 f.write_str(&self.label)
1726 }
1727}
1728
1729impl GameState {
1730 pub fn selected_quest_offer(&self) -> Option<&flatland_protocol::QuestOffer> {
1731 self.pending_quest_offers.get(self.quest_offer_index)
1732 }
1733
1734 pub fn push_quest_offer(&mut self, offer: flatland_protocol::QuestOffer) {
1735 if self
1736 .pending_quest_offers
1737 .iter()
1738 .any(|existing| existing.quest_id == offer.quest_id)
1739 {
1740 self.show_quest_offer = true;
1741 return;
1742 }
1743 self.pending_quest_offers.push(offer);
1744 self.show_quest_offer = true;
1745 }
1746
1747 pub fn remove_quest_offer(&mut self, quest_id: &str) {
1748 self.pending_quest_offers
1749 .retain(|offer| offer.quest_id != quest_id);
1750 if self.pending_quest_offers.is_empty() {
1751 self.show_quest_offer = false;
1752 self.quest_offer_index = 0;
1753 return;
1754 }
1755 self.quest_offer_index = self
1756 .quest_offer_index
1757 .min(self.pending_quest_offers.len() - 1);
1758 self.show_quest_offer = true;
1759 }
1760
1761 pub fn clear_quest_offers(&mut self) {
1762 self.pending_quest_offers.clear();
1763 self.quest_offer_index = 0;
1764 self.show_quest_offer = false;
1765 }
1766
1767 pub fn move_quest_offer_selection(&mut self, delta: i32) {
1768 let n = self.pending_quest_offers.len();
1769 if n == 0 {
1770 self.quest_offer_index = 0;
1771 return;
1772 }
1773 let idx = self.quest_offer_index as i32;
1774 self.quest_offer_index = (idx + delta).rem_euclid(n as i32) as usize;
1775 }
1776
1777 pub fn push_log(&mut self, line: impl Into<String>) {
1778 self.logs.push_back(line.into());
1779 while self.logs.len() > MAX_LOG_LINES {
1780 self.logs.pop_front();
1781 }
1782 }
1783
1784 pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1785 self.shop_trade_log.push_back(line.into());
1786 while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1787 self.shop_trade_log.pop_front();
1788 }
1789 }
1790
1791 pub fn clear_shop_trade_log(&mut self) {
1792 self.shop_trade_log.clear();
1793 }
1794
1795 fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1796 if !self.show_shop_menu {
1797 return;
1798 }
1799 let msg = notice.message.trim();
1800 if msg.is_empty() {
1801 return;
1802 }
1803 if notice.coins_delta != 0
1804 || msg.starts_with("Bought ")
1805 || msg.starts_with("Sold ")
1806 || msg.contains("taught you how to craft")
1807 || msg.starts_with("need ")
1808 {
1809 self.push_shop_trade_log(msg);
1810 }
1811 }
1812
1813 pub fn is_alive(&self) -> bool {
1814 self.player
1815 .as_ref()
1816 .and_then(|p| p.vitals)
1817 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1818 .unwrap_or(true)
1819 }
1820
1821 pub fn push_audio(&mut self, cue: crate::social::AudioCue) {
1822 self.social_chat.push_cue(cue);
1823 }
1824
1825 fn sync_gameplay_audio(&mut self) {
1827 use crate::social::AudioCue;
1828 use flatland_protocol::PrimaryAttributes;
1829
1830 let alive = self.is_alive();
1831 let casting = self.cast_progress.is_some();
1832 let telegraph = self.focus_attack_telegraph_active();
1833 let in_aoe = self.player_inside_spatial_telegraph();
1834 let quest_sig = self.quest_audio_signature();
1835 let entity_id = self.entity_id;
1836 let char_level = self
1837 .player
1838 .as_ref()
1839 .and_then(|p| p.attributes)
1840 .map(|a| {
1841 PrimaryAttributes::display(a.strength)
1842 .saturating_add(PrimaryAttributes::display(a.dexterity))
1843 .saturating_add(PrimaryAttributes::display(a.intelligence))
1844 .saturating_add(PrimaryAttributes::display(a.stamina))
1845 .saturating_add(PrimaryAttributes::display(a.vitality))
1846 .saturating_add(PrimaryAttributes::display(a.wisdom))
1847 .saturating_add(PrimaryAttributes::display(a.charisma))
1848 })
1849 .unwrap_or(0);
1850
1851 let fx_ids: Vec<u64> = self.combat_fx.iter().map(|fx| fx.id).collect();
1852 let mut hit_cues = Vec::new();
1853 {
1854 let seen = &self.social_chat.audio_seen_fx_ids;
1855 for fx in &self.combat_fx {
1856 if seen.contains(&fx.id) {
1857 continue;
1858 }
1859 let Some(hit) = fx.hits.iter().find(|h| h.entity_id == entity_id) else {
1860 continue;
1861 };
1862 if hit.outcome == CombatFxHitOutcome::Blocked {
1863 hit_cues.push(AudioCue::CombatBlock);
1864 } else {
1865 let heavy = matches!(
1866 fx.kind,
1867 CombatFxKind::Sphere | CombatFxKind::Cone | CombatFxKind::Beam
1868 );
1869 hit_cues.push(if heavy {
1870 AudioCue::CombatHitHeavy
1871 } else {
1872 AudioCue::CombatHitLight
1873 });
1874 }
1875 }
1876 }
1877
1878 let audio = &mut self.social_chat;
1879 if !audio.audio_bootstrapped {
1880 audio.audio_was_alive = alive;
1881 audio.audio_was_casting = casting;
1882 audio.audio_had_target_telegraph = telegraph;
1883 audio.audio_was_in_aoe = in_aoe;
1884 audio.audio_quest_sig = quest_sig;
1885 audio.audio_char_level = char_level;
1886 audio.audio_seen_fx_ids = fx_ids;
1887 audio.audio_bootstrapped = true;
1888 return;
1889 }
1890
1891 if telegraph && !audio.audio_had_target_telegraph {
1892 audio.push_cue(AudioCue::CombatTelegraphStart);
1893 } else if !telegraph && audio.audio_had_target_telegraph {
1894 audio.push_cue(AudioCue::CombatTelegraphImpact);
1895 }
1896 audio.audio_had_target_telegraph = telegraph;
1897
1898 if in_aoe && !audio.audio_was_in_aoe {
1899 audio.push_cue(AudioCue::CombatAoeWarn);
1900 }
1901 audio.audio_was_in_aoe = in_aoe;
1902
1903 if casting && !audio.audio_was_casting {
1904 audio.push_cue(AudioCue::AbilityCastSelf);
1905 }
1906 audio.audio_was_casting = casting;
1907
1908 if !alive && audio.audio_was_alive {
1909 audio.push_cue(AudioCue::PlayerDeath);
1910 }
1911 audio.audio_was_alive = alive;
1912
1913 if quest_sig != audio.audio_quest_sig && audio.audio_quest_sig != 0 {
1914 audio.push_cue(AudioCue::QuestUpdate);
1915 }
1916 audio.audio_quest_sig = quest_sig;
1917
1918 if char_level > audio.audio_char_level && audio.audio_char_level > 0 {
1919 audio.push_cue(AudioCue::LevelUp);
1920 }
1921 audio.audio_char_level = char_level;
1922
1923 for cue in hit_cues {
1924 audio.push_cue(cue);
1925 }
1926 audio.audio_seen_fx_ids = fx_ids;
1927 }
1928
1929 fn focus_attack_telegraph_active(&self) -> bool {
1930 let Some(tid) = self.combat_target else {
1931 return false;
1932 };
1933 self.entities
1934 .iter()
1935 .find(|e| e.id == tid)
1936 .map(|e| {
1937 e.combat_cues.iter().any(|c| {
1938 matches!(c.kind, CombatCueKind::AttackTelegraph) && c.until_tick > self.tick
1939 })
1940 })
1941 .unwrap_or(false)
1942 }
1943
1944 fn player_inside_spatial_telegraph(&self) -> bool {
1945 let (px, py) = self.player_position();
1946 for e in &self.entities {
1947 for cue in &e.combat_cues {
1948 if !matches!(cue.kind, CombatCueKind::AttackTelegraph)
1949 || cue.until_tick <= self.tick
1950 {
1951 continue;
1952 }
1953 let Some(kind) = cue.telegraph_kind else {
1954 continue;
1955 };
1956 let (ox, oy) = match (cue.origin_x, cue.origin_y) {
1957 (Some(x), Some(y)) => (x, y),
1958 _ => continue,
1959 };
1960 match kind {
1961 CombatFxKind::Sphere => {
1962 let r = cue.radius_m.unwrap_or(1.0);
1963 let dx = px - ox;
1964 let dy = py - oy;
1965 if dx * dx + dy * dy <= r * r {
1966 return true;
1967 }
1968 }
1969 CombatFxKind::Cone | CombatFxKind::MeleeArc => {
1970 let reach = cue.reach_m.unwrap_or(2.0);
1971 let yaw = cue.yaw.unwrap_or(0.0);
1972 let arc = cue.arc_deg.unwrap_or(90.0).to_radians();
1973 let dx = px - ox;
1974 let dy = py - oy;
1975 let dist = (dx * dx + dy * dy).sqrt();
1976 if dist > reach || dist < 0.05 {
1977 continue;
1978 }
1979 let ang = dx.atan2(dy);
1980 let mut delta = ang - yaw;
1981 while delta > std::f32::consts::PI {
1982 delta -= std::f32::consts::TAU;
1983 }
1984 while delta < -std::f32::consts::PI {
1985 delta += std::f32::consts::TAU;
1986 }
1987 if delta.abs() <= arc * 0.5 {
1988 return true;
1989 }
1990 }
1991 _ => {}
1992 }
1993 }
1994 }
1995 false
1996 }
1997
1998 fn quest_audio_signature(&self) -> u64 {
1999 use std::collections::hash_map::DefaultHasher;
2000 use std::hash::{Hash, Hasher};
2001 let mut h = DefaultHasher::new();
2002 for q in &self.quest_log {
2003 q.quest_id.hash(&mut h);
2004 format!("{:?}", q.status).hash(&mut h);
2005 q.current_step_id.hash(&mut h);
2006 for o in &q.objectives {
2007 o.done.hash(&mut h);
2008 o.current.hash(&mut h);
2009 }
2010 }
2011 h.finish()
2012 }
2013
2014 pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
2016 let Some(ref id) = self.npc_verb_target else {
2017 return vec![];
2018 };
2019 let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
2020 return self.with_quest_verbs(id, vec![Self::talk_choice()]);
2021 };
2022 let role = npc.role.as_str();
2023 let rest = if Self::npc_role_is_bank(role) {
2024 vec![
2025 NpcVerbChoice {
2026 label: "Bank".into(),
2027 action: NpcVerbAction::Bank,
2028 },
2029 Self::talk_choice(),
2030 ]
2031 } else if Self::npc_role_is_storage(role) {
2032 vec![
2033 NpcVerbChoice {
2034 label: "Storage".into(),
2035 action: NpcVerbAction::Storage,
2036 },
2037 Self::talk_choice(),
2038 ]
2039 } else if Self::npc_role_is_market(role) {
2040 vec![
2041 NpcVerbChoice {
2042 label: "Market".into(),
2043 action: NpcVerbAction::Market,
2044 },
2045 Self::talk_choice(),
2046 ]
2047 } else if npc.can_trade || Self::npc_role_can_trade(role) {
2048 vec![
2049 Self::talk_choice(),
2050 NpcVerbChoice {
2051 label: "Trade".into(),
2052 action: NpcVerbAction::Trade,
2053 },
2054 ]
2055 } else {
2056 vec![Self::talk_choice()]
2057 };
2058 self.with_quest_verbs(id, rest)
2059 }
2060
2061 fn talk_choice() -> NpcVerbChoice {
2062 NpcVerbChoice {
2063 label: "Talk".into(),
2064 action: NpcVerbAction::Talk,
2065 }
2066 }
2067
2068 fn with_quest_verbs(&self, npc_id: &str, rest: Vec<NpcVerbChoice>) -> Vec<NpcVerbChoice> {
2069 let mut opts = self.quest_verb_choices(npc_id);
2070 opts.extend(rest);
2071 opts
2072 }
2073
2074 fn quest_verb_choices(&self, npc_id: &str) -> Vec<NpcVerbChoice> {
2075 if let Some(npc) = self.npcs.iter().find(|n| n.id == npc_id) {
2076 if !npc.quest_verbs.is_empty() {
2077 return npc
2078 .quest_verbs
2079 .iter()
2080 .map(|v| {
2081 let action = if v.kind == flatland_protocol::NpcQuestVerb::KIND_GIVE {
2082 NpcVerbAction::QuestGive {
2083 quest_id: v.quest_id.clone(),
2084 }
2085 } else {
2086 NpcVerbAction::QuestTalk {
2087 quest_id: v.quest_id.clone(),
2088 }
2089 };
2090 NpcVerbChoice {
2091 label: v.label.clone(),
2092 action,
2093 }
2094 })
2095 .collect();
2096 }
2097 }
2098 let catalog_ref = self.npc_quest_catalog_ref(npc_id);
2099 let mut opts = Vec::new();
2100 for q in &self.quest_log {
2101 if q.status != flatland_protocol::QuestStatusView::Active {
2102 continue;
2103 }
2104 let title = if q.title.trim().is_empty() {
2105 "Quest".to_string()
2106 } else {
2107 q.title.clone()
2108 };
2109 for o in &q.objectives {
2110 if o.done || o.npc_ref.as_deref() != Some(catalog_ref.as_str()) {
2111 continue;
2112 }
2113 if o.kind == "give_item" {
2114 opts.push(NpcVerbChoice {
2115 label: format!("Turn in: {title}"),
2116 action: NpcVerbAction::QuestGive {
2117 quest_id: q.quest_id.clone(),
2118 },
2119 });
2120 } else if o.kind == "talk_npc" {
2121 opts.push(NpcVerbChoice {
2122 label: title.clone(),
2123 action: NpcVerbAction::QuestTalk {
2124 quest_id: q.quest_id.clone(),
2125 },
2126 });
2127 }
2128 }
2129 }
2130 opts
2131 }
2132
2133 fn npc_quest_catalog_ref(&self, npc_id: &str) -> String {
2134 self.npcs
2135 .iter()
2136 .find(|n| n.id == npc_id)
2137 .and_then(|n| n.paperdoll_ref.clone())
2138 .unwrap_or_else(|| npc_id.to_string())
2139 }
2140
2141 fn count_inventory_template(&self, template: &str) -> u32 {
2142 self.inventory_stacks
2143 .iter()
2144 .filter(|s| s.template_id == template)
2145 .map(|s| s.quantity)
2146 .sum()
2147 }
2148
2149 fn npc_role_can_trade(role: &str) -> bool {
2150 matches!(role, "broker" | "cook" | "farmer" | "merchant")
2151 }
2152
2153 fn npc_role_is_bank(role: &str) -> bool {
2154 role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
2155 }
2156
2157 fn npc_role_is_storage(role: &str) -> bool {
2158 role.eq_ignore_ascii_case("storage_manager")
2159 }
2160
2161 fn npc_role_is_market(role: &str) -> bool {
2162 role.eq_ignore_ascii_case("market_clerk")
2163 }
2164
2165 pub fn bank_menu_options(&self) -> Vec<&'static str> {
2166 vec![
2167 "Deposit…",
2168 "Withdraw…",
2169 "Deposit all",
2170 "Withdraw all",
2171 "Transfer…",
2172 ]
2173 }
2174
2175 pub fn storage_menu_options(&self) -> Vec<String> {
2176 let mut opts = vec!["Store…".into(), "Take…".into()];
2177 if let Some(panel) = &self.storage_panel {
2178 for dest in &panel.ship_destinations {
2179 opts.push(format!(
2180 "Ship → {} ({} cp / {} ticks)",
2181 dest.label, dest.fee_copper, dest.travel_ticks
2182 ));
2183 }
2184 }
2185 opts
2186 }
2187
2188 pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
2192 let equipped = self.hand_equipped_instance_ids();
2193 self.person_rows()
2194 .into_iter()
2195 .filter(|r| r.depth == 0)
2196 .filter_map(|r| {
2197 let id = r.stack.item_instance_id?;
2198 if equipped.contains(&id) {
2199 return None;
2200 }
2201 Some(StoragePickOption {
2202 item_instance_id: id,
2203 template_id: r.stack.template_id.clone(),
2204 label: storage_stack_label(&r.stack),
2205 quantity: r.stack.quantity,
2206 category: r.stack.category.clone().unwrap_or_default(),
2207 })
2208 })
2209 .collect()
2210 }
2211
2212 pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
2214 let mut ids = std::collections::HashSet::new();
2215 if let Some(id) = self.mainhand_instance_id {
2216 ids.insert(id);
2217 } else if let Some(tid) = &self.mainhand_template_id {
2218 if let Some(id) = self
2219 .inventory_stacks
2220 .iter()
2221 .find(|s| &s.template_id == tid)
2222 .and_then(|s| s.item_instance_id)
2223 {
2224 ids.insert(id);
2225 }
2226 }
2227 if let Some(id) = self.offhand_instance_id {
2228 ids.insert(id);
2229 } else if let Some(tid) = &self.offhand_template_id {
2230 if let Some(id) = self
2231 .inventory_stacks
2232 .iter()
2233 .find(|s| {
2234 &s.template_id == tid
2235 && s.item_instance_id.is_some_and(|iid| !ids.contains(&iid))
2236 })
2237 .and_then(|s| s.item_instance_id)
2238 {
2239 ids.insert(id);
2240 }
2241 }
2242 ids
2243 }
2244
2245 pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
2247 let Some(panel) = &self.storage_panel else {
2248 return Vec::new();
2249 };
2250 panel
2251 .contents
2252 .iter()
2253 .filter_map(|s| {
2254 let id = s.item_instance_id?;
2255 Some(StoragePickOption {
2256 item_instance_id: id,
2257 template_id: s.template_id.clone(),
2258 label: storage_stack_label(s),
2259 quantity: s.quantity,
2260 category: s.category.clone().unwrap_or_default(),
2261 })
2262 })
2263 .collect()
2264 }
2265
2266 pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
2268 let mut opts = Vec::new();
2269 if !self
2270 .market_list_item_options(&MarketListSourceKind::Person)
2271 .is_empty()
2272 {
2273 opts.push((MarketListSourceKind::Person, "On person".into()));
2274 }
2275 if let Some(panel) = &self.market_panel {
2276 for vault in &panel.list_vaults {
2277 let source = MarketListSourceKind::TownStorage {
2278 building_id: vault.building_id.clone(),
2279 };
2280 if self.market_list_item_options(&source).is_empty() {
2281 continue;
2282 }
2283 let label = if vault.building_label.is_empty() {
2284 format!("Town storage ({})", vault.building_id)
2285 } else {
2286 format!("Town storage — {}", vault.building_label)
2287 };
2288 opts.push((source, label));
2289 }
2290 }
2291 opts
2292 }
2293
2294 pub fn market_list_item_options(
2296 &self,
2297 source: &MarketListSourceKind,
2298 ) -> Vec<StoragePickOption> {
2299 let filter = self.market_filter.as_str();
2300 let cat_filter = self.market_category_filter;
2301 let mut opts: Vec<StoragePickOption> = match source {
2302 MarketListSourceKind::Person => {
2303 let equipped = self.hand_equipped_instance_ids();
2304 self.person_rows()
2305 .into_iter()
2306 .filter(|r| r.depth == 0)
2307 .filter(|r| self.stack_is_market_listable(&r.stack))
2308 .filter_map(|r| {
2309 let id = r.stack.item_instance_id?;
2310 if equipped.contains(&id) {
2311 return None;
2312 }
2313 Some(StoragePickOption {
2314 item_instance_id: id,
2315 template_id: r.stack.template_id.clone(),
2316 label: storage_stack_label(&r.stack),
2317 quantity: r.stack.quantity,
2318 category: r
2319 .stack
2320 .category
2321 .clone()
2322 .or_else(|| {
2323 self.inventory_item_category(&r.stack.template_id)
2324 .map(str::to_string)
2325 })
2326 .unwrap_or_default(),
2327 })
2328 })
2329 .collect()
2330 }
2331 MarketListSourceKind::TownStorage { building_id } => {
2332 let Some(panel) = &self.market_panel else {
2333 return Vec::new();
2334 };
2335 let Some(vault) = panel
2336 .list_vaults
2337 .iter()
2338 .find(|v| &v.building_id == building_id)
2339 else {
2340 return Vec::new();
2341 };
2342 vault
2343 .contents
2344 .iter()
2345 .filter(|s| self.stack_is_market_listable(s))
2346 .filter_map(|s| {
2347 let id = s.item_instance_id?;
2348 Some(StoragePickOption {
2349 item_instance_id: id,
2350 template_id: s.template_id.clone(),
2351 label: storage_stack_label(s),
2352 quantity: s.quantity,
2353 category: s
2354 .category
2355 .clone()
2356 .or_else(|| {
2357 self.inventory_item_category(&s.template_id)
2358 .map(str::to_string)
2359 })
2360 .unwrap_or_default(),
2361 })
2362 })
2363 .collect()
2364 }
2365 };
2366 opts.retain(|o| {
2367 if !list_label_matches(&o.label, filter) {
2368 return false;
2369 }
2370 if let Some(group) = cat_filter {
2371 inventory_category_group(&o.category).0 == group
2372 } else {
2373 true
2374 }
2375 });
2376 opts
2377 }
2378
2379 pub fn item_base_value_copper_hint(&self, template_id: &str) -> Option<u32> {
2381 if let Some(hint) = self.inventory_hints.get(template_id) {
2382 if let Some(v) = hint.base_value_copper.filter(|v| *v > 0) {
2383 return Some(v);
2384 }
2385 }
2386 if let Some(v) = self
2387 .inventory_stacks
2388 .iter()
2389 .find(|s| s.template_id == template_id)
2390 .and_then(|s| s.base_value_copper.filter(|v| *v > 0))
2391 {
2392 return Some(v);
2393 }
2394 self.market_panel.as_ref().and_then(|panel| {
2395 panel.list_vaults.iter().find_map(|vault| {
2396 vault.contents.iter().find_map(|stack| {
2397 (stack.template_id == template_id)
2398 .then(|| stack.base_value_copper.filter(|v| *v > 0))
2399 .flatten()
2400 })
2401 })
2402 })
2403 }
2404
2405 pub fn npc_market_dump_unit_estimate(&self, template_id: &str) -> Option<u32> {
2407 let base = self.item_base_value_copper_hint(template_id)?;
2408 npc_market_dump_unit_estimate_copper(base)
2409 }
2410
2411 fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
2412 if crate::currency::is_currency(&stack.template_id) {
2413 return false;
2414 }
2415 if let Some(flag) = stack.listable {
2416 return flag;
2417 }
2418 if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
2419 return hint.listable;
2420 }
2421 let cat = stack
2422 .category
2423 .as_deref()
2424 .or_else(|| self.inventory_item_category(&stack.template_id))
2425 .unwrap_or("");
2426 category_default_listable(cat)
2427 }
2428
2429 pub fn market_available_category_groups(&self) -> Vec<&'static str> {
2431 let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
2432 match &self.market_ui_mode {
2433 MarketUiMode::ListPick { source, .. } => {
2434 let raw: Vec<_> = match source {
2435 MarketListSourceKind::Person => self
2436 .person_rows()
2437 .into_iter()
2438 .filter(|r| r.depth == 0)
2439 .filter(|r| self.stack_is_market_listable(&r.stack))
2440 .filter(|r| {
2441 list_label_matches(&storage_stack_label(&r.stack), &self.market_filter)
2442 })
2443 .map(|r| {
2444 r.stack
2445 .category
2446 .clone()
2447 .or_else(|| {
2448 self.inventory_item_category(&r.stack.template_id)
2449 .map(str::to_string)
2450 })
2451 .unwrap_or_default()
2452 })
2453 .collect(),
2454 MarketListSourceKind::TownStorage { building_id } => self
2455 .market_panel
2456 .as_ref()
2457 .and_then(|p| p.list_vaults.iter().find(|v| &v.building_id == building_id))
2458 .map(|vault| {
2459 vault
2460 .contents
2461 .iter()
2462 .filter(|s| self.stack_is_market_listable(s))
2463 .filter(|s| {
2464 list_label_matches(&storage_stack_label(s), &self.market_filter)
2465 })
2466 .map(|s| {
2467 s.category
2468 .clone()
2469 .or_else(|| {
2470 self.inventory_item_category(&s.template_id)
2471 .map(str::to_string)
2472 })
2473 .unwrap_or_default()
2474 })
2475 .collect::<Vec<_>>()
2476 })
2477 .unwrap_or_default(),
2478 };
2479 for category in raw {
2480 let (label, ord) = inventory_category_group(&category);
2481 seen.insert(ord, label);
2482 }
2483 }
2484 _ => {
2485 if let Some(panel) = &self.market_panel {
2486 for listing in &panel.listings {
2487 if !list_label_matches(&listing.display_name, &self.market_filter)
2488 && !list_label_matches(&listing.seller_label, &self.market_filter)
2489 {
2490 continue;
2491 }
2492 let (label, ord) = inventory_category_group(&listing.category);
2493 seen.insert(ord, label);
2494 }
2495 }
2496 }
2497 }
2498 seen.into_values().collect()
2499 }
2500
2501 pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
2503 let Some(panel) = &self.market_panel else {
2504 return Vec::new();
2505 };
2506 let filter = self.market_filter.as_str();
2507 let cat_filter = self.market_category_filter;
2508 panel
2509 .listings
2510 .iter()
2511 .enumerate()
2512 .filter(|(_, listing)| {
2513 if !list_label_matches(&listing.display_name, filter)
2514 && !list_label_matches(&listing.seller_label, filter)
2515 && !list_label_matches(&listing.template_id, filter)
2516 {
2517 return false;
2518 }
2519 if let Some(group) = cat_filter {
2520 inventory_category_group(&listing.category).0 == group
2521 } else {
2522 true
2523 }
2524 })
2525 .map(|(i, _)| i)
2526 .collect()
2527 }
2528
2529 pub fn clear_harvest_state(&mut self) {
2530 self.harvest_in_progress = false;
2531 self.harvest_started_at = None;
2532 }
2533
2534 fn harvest_state_stale(&self) -> bool {
2535 match self.harvest_started_at {
2536 Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
2537 None => self.harvest_in_progress,
2538 }
2539 }
2540
2541 pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
2542 self.player.as_ref().and_then(|p| p.vitals)
2543 }
2544
2545 pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
2546 let materials_ok = blueprint.inputs.iter().all(|input| {
2547 self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
2548 });
2549 let tools_ok = blueprint
2550 .required_tools
2551 .iter()
2552 .all(|tool| self.player_has_craft_tool(&tool.item));
2553 let station_ok = match blueprint.station.as_deref() {
2554 None | Some("hand") => true,
2555 Some(tag) => self.player_at_station_tag(tag),
2556 };
2557 materials_ok
2558 && tools_ok
2559 && station_ok
2560 && self.craft_has_vessel_room_for_output(blueprint)
2561 }
2562
2563 pub fn player_has_craft_tool(&self, tool_template: &str) -> bool {
2565 if self.inventory.get(tool_template).copied().unwrap_or(0) >= 1 {
2566 return true;
2567 }
2568 let Some(player) = self.player.as_ref() else {
2569 return false;
2570 };
2571 let px = player.transform.position.x;
2572 let py = player.transform.position.y;
2573 const RANGE: f32 = 3.0;
2575 self.placed_containers.iter().any(|c| {
2576 if c.template_id != tool_template {
2577 return false;
2578 }
2579 if !self.placed_container_in_current_space(c) {
2580 return false;
2581 }
2582 let dx = c.x - px;
2583 let dy = c.y - py;
2584 dx * dx + dy * dy <= RANGE * RANGE
2585 })
2586 }
2587
2588 fn craft_output_needs_vessel(&self, blueprint: &BlueprintView) -> bool {
2590 matches!(
2591 self.inventory_item_category(&blueprint.output),
2592 Some("bulk") | Some("liquid")
2593 ) || matches!(
2594 blueprint.output.as_str(),
2595 "dirt" | "mud" | "sand" | "water" | "milk"
2596 )
2597 }
2598
2599 fn craft_output_category(&self, blueprint: &BlueprintView) -> Option<&str> {
2600 self.inventory_item_category(&blueprint.output).or_else(|| {
2601 match blueprint.output.as_str() {
2602 "dirt" | "mud" | "sand" => Some("bulk"),
2603 "water" | "milk" => Some("liquid"),
2604 _ => None,
2605 }
2606 })
2607 }
2608
2609 fn craft_has_vessel_room_for_output(&self, blueprint: &BlueprintView) -> bool {
2610 if !self.craft_output_needs_vessel(blueprint) {
2611 return true;
2612 }
2613 let need = blueprint.output_qty.max(1);
2614 self.vessel_room_after_craft_inputs(blueprint) >= need
2615 }
2616
2617 fn vessel_room_after_craft_inputs(&self, blueprint: &BlueprintView) -> u32 {
2619 let mut stacks = self.inventory_stacks.clone();
2620 for worn in self.worn.values() {
2621 stacks.push(worn.clone());
2622 }
2623 for input in &blueprint.inputs {
2624 let mut left = input.quantity;
2625 drain_payload_from_stacks(&mut stacks, &input.template_id, &mut left);
2626 if left > 0 {
2627 return 0;
2628 }
2629 }
2630 vessel_room_for_payload_in_stacks(
2631 &stacks,
2632 &blueprint.output,
2633 self.craft_output_category(blueprint),
2634 )
2635 }
2636
2637 pub fn craft_vessel_status(&self, blueprint: &BlueprintView) -> CraftVesselStatus {
2639 let output_label = self.blueprint_output_label(blueprint);
2640 let needs_vessel = self.craft_output_needs_vessel(blueprint);
2641 let need_units = if needs_vessel {
2642 blueprint.output_qty.max(1)
2643 } else {
2644 0
2645 };
2646 let free_after_inputs = if needs_vessel {
2647 self.vessel_room_after_craft_inputs(blueprint)
2648 } else {
2649 0
2650 };
2651 let payload_cat = self.craft_output_category(blueprint);
2652 let mut vessels = Vec::new();
2653 Self::collect_craft_vessel_lines(
2654 &self.inventory_stacks,
2655 "pack",
2656 &blueprint.output,
2657 payload_cat,
2658 &mut vessels,
2659 );
2660 for worn in self.worn.values() {
2661 Self::collect_craft_vessel_lines(
2662 std::slice::from_ref(worn),
2663 "worn",
2664 &blueprint.output,
2665 payload_cat,
2666 &mut vessels,
2667 );
2668 }
2669 CraftVesselStatus {
2670 needs_vessel,
2671 output_label,
2672 need_units,
2673 free_after_inputs,
2674 ok: !needs_vessel || free_after_inputs >= need_units,
2675 vessels,
2676 }
2677 }
2678
2679 fn collect_craft_vessel_lines(
2680 stacks: &[flatland_protocol::ItemStack],
2681 location: &'static str,
2682 payload_id: &str,
2683 payload_category: Option<&str>,
2684 out: &mut Vec<CraftVesselLine>,
2685 ) {
2686 for stack in stacks {
2687 if is_serving_vessel_stack(stack) {
2688 let cap = serving_capacity_of(stack);
2689 let used = payload_units_in_vessel(stack);
2690 let free = vessel_free_room_for_payload(stack, payload_id, payload_category);
2691 let holds = stack
2692 .props
2693 .get("serving_holds")
2694 .cloned()
2695 .unwrap_or_else(|| {
2696 if stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
2697 && stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
2698 {
2699 "liquid,bulk".into()
2700 } else if stack.props.get("bulk_vessel").is_some_and(|v| v == "1") {
2701 "bulk".into()
2702 } else if stack.props.get("liquid_vessel").is_some_and(|v| v == "1") {
2703 "liquid".into()
2704 } else {
2705 "?".into()
2706 }
2707 });
2708 let label = stack
2709 .display_name
2710 .clone()
2711 .unwrap_or_else(|| stack.template_id.clone());
2712 out.push(CraftVesselLine {
2713 label,
2714 holds,
2715 capacity: cap,
2716 used,
2717 free,
2718 quantity: stack.quantity.max(1),
2719 accepts_output: free > 0,
2720 location,
2721 });
2722 }
2723 Self::collect_craft_vessel_lines(
2724 &stack.contents,
2725 location,
2726 payload_id,
2727 payload_category,
2728 out,
2729 );
2730 }
2731 }
2732
2733 fn craft_prefs_key(&self) -> String {
2734 if let Some(cid) = self.character_id {
2735 cid.to_string()
2736 } else if self.entity_id != 0 {
2737 format!("entity:{}", self.entity_id)
2738 } else {
2739 String::new()
2740 }
2741 }
2742
2743 pub fn reload_craft_prefs(&mut self) {
2744 let key = self.craft_prefs_key();
2745 self.craft_prefs = crate::craft_prefs::load_for_character(&key);
2746 }
2747
2748 fn persist_craft_prefs(&self) {
2749 crate::craft_prefs::save_for_character(&self.craft_prefs_key(), &self.craft_prefs);
2750 }
2751
2752 pub fn craft_known_tiers(&self) -> Vec<u32> {
2754 let mut tiers: Vec<u32> = self
2755 .blueprints
2756 .iter()
2757 .map(|bp| bp.craft_tier.max(1))
2758 .collect::<std::collections::BTreeSet<_>>()
2759 .into_iter()
2760 .collect();
2761 tiers.sort_unstable();
2762 tiers
2763 }
2764
2765 pub fn craft_tab_strip(&self) -> Vec<CraftTab> {
2767 let mut tabs = vec![CraftTab::Ready, CraftTab::Favorites, CraftTab::Recent];
2768 for t in self.craft_known_tiers() {
2769 tabs.push(CraftTab::Tier(t));
2770 }
2771 tabs
2772 }
2773
2774 pub fn craft_set_tab(&mut self, tab: CraftTab) {
2775 self.craft_tab = tab;
2776 self.craft_menu_index = 0;
2777 self.clamp_craft_menu_index();
2778 self.clamp_craft_batch_quantity();
2779 }
2780
2781 pub fn craft_cycle_tab(&mut self, delta: i32) {
2782 let tabs = self.craft_tab_strip();
2783 if tabs.is_empty() {
2784 return;
2785 }
2786 let cur = tabs
2787 .iter()
2788 .position(|t| *t == self.craft_tab)
2789 .unwrap_or(0) as i32;
2790 let next = (cur + delta).rem_euclid(tabs.len() as i32) as usize;
2791 self.craft_set_tab(tabs[next]);
2792 }
2793
2794 pub fn craft_matches_search(&self, bp: &BlueprintView) -> bool {
2795 let f = self.craft_filter.trim();
2796 if f.is_empty() {
2797 return true;
2798 }
2799 if list_label_matches(&bp.label, f)
2800 || list_label_matches(&bp.output, f)
2801 || list_label_matches(&bp.output_display_name, f)
2802 || bp
2803 .category
2804 .as_deref()
2805 .is_some_and(|c| list_label_matches(c, f))
2806 || bp
2807 .station
2808 .as_deref()
2809 .is_some_and(|s| list_label_matches(s, f))
2810 {
2811 return true;
2812 }
2813 bp.inputs.iter().any(|i| {
2814 list_label_matches(&i.template_id, f) || list_label_matches(&i.display_name, f)
2815 }) || bp.required_tools.iter().any(|t| {
2816 list_label_matches(&t.item, f) || list_label_matches(&t.display_name, f)
2817 })
2818 }
2819
2820 pub fn craft_blueprint_in_channel(&self, blueprint_id: &str) -> bool {
2822 self.craft_channel_blueprint_id.as_deref() == Some(blueprint_id)
2823 && self.active_craft_channel().is_some()
2824 }
2825
2826 pub fn craft_filtered_indices(&self) -> Vec<usize> {
2828 let mut idxs: Vec<usize> = (0..self.blueprints.len())
2829 .filter(|&i| {
2830 let bp = &self.blueprints[i];
2831 if !self.craft_matches_search(bp) {
2832 return false;
2833 }
2834 match self.craft_tab {
2835 CraftTab::Ready => {
2836 self.can_craft_blueprint(bp) || self.craft_blueprint_in_channel(&bp.id)
2837 }
2838 CraftTab::Favorites => self.craft_prefs.is_favorite(&bp.id),
2839 CraftTab::Recent => self.craft_prefs.recent.iter().any(|id| id == &bp.id),
2840 CraftTab::Tier(t) => bp.craft_tier.max(1) == t,
2841 }
2842 })
2843 .collect();
2844 match self.craft_tab {
2845 CraftTab::Recent => {
2846 idxs.sort_by_key(|&i| {
2847 self.craft_prefs
2848 .recent
2849 .iter()
2850 .position(|id| id == &self.blueprints[i].id)
2851 .unwrap_or(usize::MAX)
2852 });
2853 }
2854 _ => {
2855 idxs.sort_by(|&a, &b| {
2856 let ba = &self.blueprints[a];
2857 let bb = &self.blueprints[b];
2858 let ia = self.craft_blueprint_in_channel(&ba.id);
2859 let ib = self.craft_blueprint_in_channel(&bb.id);
2860 ib.cmp(&ia)
2862 .then_with(|| {
2863 let ra = self.can_craft_blueprint(ba);
2864 let rb = self.can_craft_blueprint(bb);
2865 rb.cmp(&ra)
2866 })
2867 .then_with(|| ba.label.to_ascii_lowercase().cmp(&bb.label.to_ascii_lowercase()))
2868 });
2869 }
2870 }
2871 idxs
2872 }
2873
2874 pub fn craft_selected_blueprint(&self) -> Option<&BlueprintView> {
2875 let idxs = self.craft_filtered_indices();
2876 idxs.get(self.craft_menu_index)
2877 .and_then(|&i| self.blueprints.get(i))
2878 }
2879
2880 pub fn clamp_craft_menu_index(&mut self) {
2881 let n = self.craft_filtered_indices().len();
2882 if n == 0 {
2883 self.craft_menu_index = 0;
2884 } else {
2885 self.craft_menu_index = self.craft_menu_index.min(n - 1);
2886 }
2887 }
2888
2889 pub fn craft_is_favorite(&self, blueprint_id: &str) -> bool {
2890 self.craft_prefs.is_favorite(blueprint_id)
2891 }
2892
2893 pub fn craft_toggle_favorite_selected(&mut self) {
2894 let Some(id) = self.craft_selected_blueprint().map(|bp| bp.id.clone()) else {
2895 return;
2896 };
2897 self.craft_prefs.toggle_favorite(&id);
2898 self.persist_craft_prefs();
2899 if matches!(self.craft_tab, CraftTab::Favorites) {
2900 self.clamp_craft_menu_index();
2901 }
2902 }
2903
2904 pub fn craft_record_completed(&mut self, blueprint_id: &str) {
2905 self.craft_prefs.record_crafted(blueprint_id);
2906 self.persist_craft_prefs();
2907 }
2908
2909 pub fn focus_craft_filter(&mut self) {
2910 self.craft_filter_focused = true;
2911 }
2912
2913 pub fn append_craft_filter_char(&mut self, ch: char) {
2914 if !self.craft_filter_focused {
2915 return;
2916 }
2917 if is_list_filter_char(ch) {
2918 self.craft_filter.push(ch);
2919 self.craft_menu_index = 0;
2920 self.clamp_craft_menu_index();
2921 }
2922 }
2923
2924 pub fn craft_filter_backspace(&mut self) {
2925 if !self.craft_filter_focused {
2926 return;
2927 }
2928 self.craft_filter.pop();
2929 self.craft_menu_index = 0;
2930 self.clamp_craft_menu_index();
2931 }
2932
2933 pub fn clear_or_blur_craft_filter(&mut self) -> bool {
2935 if self.craft_filter_focused {
2936 if !self.craft_filter.is_empty() {
2937 self.craft_filter.clear();
2938 self.craft_menu_index = 0;
2939 self.clamp_craft_menu_index();
2940 } else {
2941 self.craft_filter_focused = false;
2942 }
2943 return true;
2944 }
2945 if !self.craft_filter.is_empty() {
2946 self.craft_filter.clear();
2947 self.craft_menu_index = 0;
2948 self.clamp_craft_menu_index();
2949 return true;
2950 }
2951 false
2952 }
2953
2954 pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
2955 if !self.can_craft_blueprint(blueprint) {
2956 return 0;
2957 }
2958 let mut limit = u32::MAX;
2959 for input in &blueprint.inputs {
2960 if input.quantity == 0 {
2961 continue;
2962 }
2963 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2964 limit = limit.min(have / input.quantity);
2965 }
2966 for tool in &blueprint.required_tools {
2967 if tool.consumed {
2968 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2969 limit = limit.min(have);
2970 }
2971 }
2972 let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
2973 if CRAFT_STAMINA_COST > 0.0 {
2974 limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
2975 }
2976 if self.craft_output_needs_vessel(blueprint) {
2977 let need = blueprint.output_qty.max(1);
2978 let room = self.vessel_room_after_craft_inputs(blueprint);
2979 if need > 0 {
2980 limit = limit.min(room / need);
2981 }
2982 }
2983 limit
2984 }
2985
2986 pub fn clamp_craft_batch_quantity(&mut self) {
2987 let Some(bp) = self.craft_selected_blueprint().cloned() else {
2988 self.craft_batch_quantity = 1;
2989 return;
2990 };
2991 let max = self.max_craft_batches(&bp).max(1);
2992 self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
2993 }
2994
2995 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
2996 let Some(bp) = self.craft_selected_blueprint().cloned() else {
2997 return;
2998 };
2999 let max = self.max_craft_batches(&bp).max(1);
3000 let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
3001 self.craft_batch_quantity = next as u32;
3002 }
3003
3004 pub fn craft_batch_set_max(&mut self) {
3005 let Some(bp) = self.craft_selected_blueprint().cloned() else {
3006 return;
3007 };
3008 let max = self.max_craft_batches(&bp);
3009 self.craft_batch_quantity = if max == 0 { 1 } else { max };
3010 }
3011
3012 pub fn craft_batch_set_min(&mut self) {
3013 self.craft_batch_quantity = 1;
3014 }
3015
3016 pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
3017 let preserve_ui = self.show_shop_menu;
3018 let tab = self.shop_tab;
3019 let index = self.shop_menu_index;
3020 let qty = self.shop_quantity;
3021
3022 self.show_shop_menu = true;
3023 self.bank_panel = None;
3024 self.show_craft_menu = false;
3025 self.show_inventory_menu = false;
3026 self.show_stats = false;
3027 if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
3028 self.npc_verb_target = Some(catalog.npc_id.clone());
3029 }
3030 self.shop_catalog = Some(catalog);
3031
3032 if preserve_ui {
3033 self.shop_tab = tab;
3034 self.shop_menu_index = index;
3035 self.shop_quantity = qty;
3036 } else {
3037 self.shop_tab = ShopTab::Buy;
3038 self.shop_menu_index = 0;
3039 self.shop_quantity = 1;
3040 self.clear_shop_trade_log();
3041 }
3042 self.show_npc_verb_menu = false;
3043 self.clamp_shop_selection();
3044 }
3045
3046 pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
3047 let same_teller = self
3048 .bank_panel
3049 .as_ref()
3050 .is_some_and(|p| p.npc_id == panel.npc_id);
3051 self.bank_panel = Some(panel);
3052 self.storage_panel = None;
3053 self.market_panel = None;
3054 self.shop_catalog = None;
3055 self.show_shop_menu = false;
3056 self.show_craft_menu = false;
3057 self.show_inventory_menu = false;
3058 self.show_stats = false;
3059 self.show_npc_verb_menu = false;
3060 self.show_npc_chat = false;
3061 self.npc_chat = None;
3062 if !same_teller {
3063 self.bank_menu_index = 0;
3064 self.bank_ui_mode = BankUiMode::Menu;
3065 }
3066 if let Some(panel) = &self.bank_panel {
3067 if self.npc_verb_target.is_none() {
3068 self.npc_verb_target = Some(panel.npc_id.clone());
3069 }
3070 }
3071 }
3072
3073 pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
3074 let same_manager = self
3075 .storage_panel
3076 .as_ref()
3077 .is_some_and(|p| p.npc_id == panel.npc_id);
3078 self.storage_panel = Some(panel);
3079 self.bank_panel = None;
3080 self.market_panel = None;
3081 self.bank_ui_mode = BankUiMode::Menu;
3082 self.shop_catalog = None;
3083 self.show_shop_menu = false;
3084 self.show_craft_menu = false;
3085 self.show_inventory_menu = false;
3086 self.show_stats = false;
3087 self.show_npc_verb_menu = false;
3088 self.show_npc_chat = false;
3089 self.npc_chat = None;
3090 if !same_manager {
3091 self.storage_menu_index = 0;
3092 self.storage_ui_mode = StorageUiMode::Menu;
3093 } else {
3094 self.clamp_storage_pick_index();
3095 }
3096 if let Some(panel) = &self.storage_panel {
3097 if self.npc_verb_target.is_none() {
3098 self.npc_verb_target = Some(panel.npc_id.clone());
3099 }
3100 }
3101 }
3102
3103 pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
3104 for vault in &panel.list_vaults {
3105 self.merge_stack_catalog_hints(&vault.contents);
3106 }
3107 self.market_panel = Some(panel);
3108 self.bank_panel = None;
3109 self.storage_panel = None;
3110 self.shop_catalog = None;
3111 self.show_shop_menu = false;
3112 self.show_craft_menu = false;
3113 self.show_inventory_menu = false;
3114 self.show_stats = false;
3115 self.show_npc_verb_menu = false;
3116 self.show_npc_chat = false;
3117 self.npc_chat = None;
3118 self.market_menu_index = 0;
3119 self.market_buy_confirm = None;
3120 self.market_ui_mode = MarketUiMode::Browse;
3121 self.market_filter.clear();
3122 self.market_filter_focused = false;
3123 self.market_category_filter = None;
3124 if let Some(panel) = &self.market_panel {
3125 if self.npc_verb_target.is_none() {
3126 self.npc_verb_target = Some(panel.npc_id.clone());
3127 }
3128 }
3129 }
3130
3131 pub fn clear_market_panel(&mut self) {
3132 self.market_panel = None;
3133 self.market_menu_index = 0;
3134 self.market_buy_confirm = None;
3135 self.market_ui_mode = MarketUiMode::Browse;
3136 self.market_filter.clear();
3137 self.market_filter_focused = false;
3138 self.market_category_filter = None;
3139 }
3140
3141 pub fn clear_bank_panel(&mut self) {
3142 self.bank_panel = None;
3143 self.bank_menu_index = 0;
3144 self.bank_ui_mode = BankUiMode::Menu;
3145 }
3146
3147 pub fn clear_storage_panel(&mut self) {
3148 self.storage_panel = None;
3149 self.storage_menu_index = 0;
3150 self.storage_ui_mode = StorageUiMode::Menu;
3151 }
3152
3153 fn clamp_storage_pick_index(&mut self) {
3154 match &self.storage_ui_mode {
3155 StorageUiMode::StorePick { index } => {
3156 let n = self.storage_store_options().len();
3157 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3158 self.storage_ui_mode = StorageUiMode::StorePick { index: next };
3159 }
3160 StorageUiMode::TakePick { index } => {
3161 let n = self.storage_vault_options().len();
3162 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3163 self.storage_ui_mode = StorageUiMode::TakePick { index: next };
3164 }
3165 StorageUiMode::ShipPick {
3166 dest_building_id,
3167 dest_label,
3168 index,
3169 } => {
3170 let n = self.storage_vault_options().len();
3171 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3172 self.storage_ui_mode = StorageUiMode::ShipPick {
3173 dest_building_id: dest_building_id.clone(),
3174 dest_label: dest_label.clone(),
3175 index: next,
3176 };
3177 }
3178 StorageUiMode::Menu
3179 | StorageUiMode::StoreAmount { .. }
3180 | StorageUiMode::TakeAmount { .. }
3181 | StorageUiMode::ShipAmount { .. } => {}
3182 }
3183 }
3184
3185 pub fn shop_list_len(&self) -> usize {
3186 let Some(catalog) = &self.shop_catalog else {
3187 return 0;
3188 };
3189 match self.shop_tab {
3190 ShopTab::Buy => catalog.sells.len(),
3191 ShopTab::Sell => catalog.buys.len(),
3192 }
3193 }
3194
3195 pub fn shop_menu_move(&mut self, delta: i32) {
3196 let n = self.shop_list_len();
3197 if n == 0 {
3198 return;
3199 }
3200 let idx = self.shop_menu_index as i32;
3201 let next = (idx + delta).rem_euclid(n as i32);
3202 self.shop_menu_index = next as usize;
3203 self.clamp_shop_quantity();
3204 }
3205
3206 pub fn shop_quantity_adjust(&mut self, delta: i32) {
3207 let max = self.shop_quantity_max();
3208 if max == 0 {
3209 self.shop_quantity = 0;
3210 return;
3211 }
3212 let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
3213 self.shop_quantity = next as u32;
3214 }
3215
3216 pub(crate) fn clamp_shop_selection(&mut self) {
3217 let n = self.shop_list_len();
3218 if n == 0 {
3219 self.shop_menu_index = 0;
3220 } else {
3221 self.shop_menu_index = self.shop_menu_index.min(n - 1);
3222 }
3223 self.clamp_shop_quantity();
3224 }
3225
3226 fn shop_quantity_max(&self) -> u32 {
3227 let Some(catalog) = &self.shop_catalog else {
3228 return 1;
3229 };
3230 match self.shop_tab {
3231 ShopTab::Buy => {
3232 if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
3233 if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
3234 return 1;
3235 }
3236 }
3237 99
3238 }
3239 ShopTab::Sell => catalog
3240 .buys
3241 .get(self.shop_menu_index)
3242 .map(|l| l.quantity)
3243 .unwrap_or(0),
3244 }
3245 }
3246
3247 pub fn shop_quantity_set_max(&mut self) {
3248 self.shop_quantity = self.shop_quantity_max();
3249 }
3250
3251 pub fn shop_quantity_set_min(&mut self) {
3252 let max = self.shop_quantity_max();
3253 self.shop_quantity = if max == 0 { 0 } else { 1 };
3254 }
3255
3256 fn clamp_shop_quantity(&mut self) {
3257 let max = self.shop_quantity_max();
3258 if max == 0 {
3259 self.shop_quantity = 0;
3260 } else {
3261 self.shop_quantity = self.shop_quantity.max(1).min(max);
3262 }
3263 }
3264
3265 pub fn player_at_station_tag(&self, tag: &str) -> bool {
3266 let Some(id) = self.effective_inside_building() else {
3267 return false;
3268 };
3269 self.buildings
3270 .iter()
3271 .find(|b| b.id == id)
3272 .is_some_and(|b| b.tags.iter().any(|t| t == tag))
3273 }
3274
3275 pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
3277 if self.can_craft_blueprint(blueprint) {
3278 return None;
3279 }
3280 let mut missing = Vec::new();
3281 for input in &blueprint.inputs {
3282 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
3283 if have < input.quantity {
3284 let name = self.blueprint_ingredient_label(input);
3285 let vessel_note = if self.inventory_item_category(&input.template_id)
3286 == Some("liquid")
3287 || matches!(input.template_id.as_str(), "water" | "milk")
3288 {
3289 "; fill a bottle/waterskin"
3290 } else if self.inventory_item_category(&input.template_id) == Some("bulk")
3291 || matches!(input.template_id.as_str(), "dirt" | "mud" | "sand")
3292 {
3293 "; scoop into a sack/bucket"
3294 } else {
3295 ""
3296 };
3297 missing.push(format!(
3298 "{}×{} (have {have}{vessel_note})",
3299 input.quantity, name
3300 ));
3301 }
3302 }
3303 for tool in &blueprint.required_tools {
3304 if !self.player_has_craft_tool(&tool.item) {
3305 missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
3306 }
3307 }
3308 if let Some(station) = blueprint.station.as_deref() {
3309 if station != "hand" && !self.player_at_station_tag(station) {
3310 missing.push(format!("station: {station} (enter building)"));
3311 }
3312 }
3313 if self.craft_output_needs_vessel(blueprint) && !self.craft_has_vessel_room_for_output(blueprint)
3314 {
3315 let name = self
3316 .inventory_hints
3317 .get(&blueprint.output)
3318 .map(|h| h.display_name.as_str())
3319 .unwrap_or(blueprint.output.as_str());
3320 let need = blueprint.output_qty.max(1);
3321 let free = self.vessel_room_after_craft_inputs(blueprint);
3322 let accepting = self
3323 .craft_vessel_status(blueprint)
3324 .vessels
3325 .iter()
3326 .filter(|v| v.accepts_output)
3327 .count();
3328 missing.push(format!(
3329 "vessel room for {name}: need {need} free after inputs, have {free} ({accepting} accepting vessel(s))"
3330 ));
3331 }
3332 if missing.is_empty() {
3333 None
3334 } else {
3335 Some(missing.join(", "))
3336 }
3337 }
3338
3339 pub fn active_craft_channel(&self) -> Option<&flatland_protocol::TimedChannelHud> {
3341 self.timed_channel
3342 .as_ref()
3343 .filter(|c| c.channel == flatland_protocol::TimedChannelKind::Craft)
3344 }
3345
3346 pub fn player_entity(&self) -> Option<&EntityState> {
3347 self.player
3348 .as_ref()
3349 .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
3350 }
3351
3352 pub fn apply_client_ui_prefs(&mut self) {
3354 let cfg = crate::client_config::ClientConfig::load();
3355 if let Some(hidden) = cfg.hud_log_hidden {
3356 self.hud_log_hidden = hidden;
3357 }
3358 if let Some(compact) = cfg.workers_menu_compact {
3359 self.workers_menu_compact = compact;
3360 }
3361 }
3362
3363 pub fn player_position(&self) -> (f32, f32) {
3364 let (x, y, _) = self.player_position_with_z();
3365 (x, y)
3366 }
3367
3368 pub fn player_position_with_z(&self) -> (f32, f32, f32) {
3369 if let Some(p) = self.player_entity() {
3370 (
3371 p.transform.position.x,
3372 p.transform.position.y,
3373 p.transform.position.z,
3374 )
3375 } else {
3376 (0.0, 0.0, 0.0)
3377 }
3378 }
3379
3380 pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
3381 let mut rows: Vec<(String, u32, String)> = self
3382 .inventory
3383 .iter()
3384 .filter(|(_, q)| **q > 0)
3385 .map(|(id, qty)| {
3386 let label = self
3387 .inventory_hints
3388 .get(id)
3389 .map(|h| h.display_name.clone())
3390 .unwrap_or_else(|| id.clone());
3391 (id.clone(), *qty, label)
3392 })
3393 .collect();
3394 rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
3395 rows
3396 }
3397
3398 pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
3399 self.inventory_hints
3400 .get(template_id)
3401 .map(|h| h.category.as_str())
3402 .filter(|c| !c.is_empty())
3403 }
3404
3405 pub fn stack_is_serving(stack: &flatland_protocol::ItemStack) -> bool {
3406 stack.props.get("serving").is_some_and(|v| v == "1")
3407 || Self::stack_is_liquid_vessel(stack)
3408 }
3409
3410 pub fn stack_is_liquid_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3411 stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
3412 || stack.props.get("serving_holds").is_some_and(|v| {
3413 v.split(',').any(|p| p.trim() == "liquid")
3414 })
3415 }
3416
3417 pub fn stack_is_food_serving(stack: &flatland_protocol::ItemStack) -> bool {
3418 stack.props.get("serving_holds").is_some_and(|v| {
3419 v.split(',').any(|p| p.trim() == "food")
3420 })
3421 }
3422
3423 pub fn stack_is_bulk_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3424 stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
3425 || stack.props.get("serving_holds").is_some_and(|v| {
3426 v.split(',').any(|p| p.trim() == "bulk")
3427 })
3428 }
3429
3430 pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
3431 stack
3432 .props
3433 .get("grants_item_status_effect")
3434 .map(|s| !s.is_empty())
3435 .unwrap_or(false)
3436 }
3437
3438 pub fn stack_is_blueprint_scroll(stack: &flatland_protocol::ItemStack) -> bool {
3439 stack
3440 .props
3441 .get("teaches_blueprint")
3442 .map(|s| !s.trim().is_empty())
3443 .unwrap_or(false)
3444 }
3445
3446 pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
3447 stack
3448 .props
3449 .get("grants_item_status_effect")
3450 .map(String::as_str)
3451 .filter(|s| !s.is_empty())
3452 }
3453
3454 pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
3455 stack
3456 .props
3457 .get("grants_item_status_mode")
3458 .map(String::as_str)
3459 .unwrap_or("on_hit")
3460 }
3461
3462 pub fn grant_target_options(
3464 &self,
3465 grant: &flatland_protocol::ItemStack,
3466 ) -> Vec<GrantTargetOption> {
3467 let mode = Self::grant_mode(grant);
3468 let grant_tags: Vec<&str> = grant
3469 .props
3470 .get("grants_item_status_tags")
3471 .map(|s| {
3472 s.split(',')
3473 .map(str::trim)
3474 .filter(|t| !t.is_empty())
3475 .collect()
3476 })
3477 .unwrap_or_default();
3478 let grant_id = grant.item_instance_id;
3479 let mut out = Vec::new();
3480 let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
3481 let Some(iid) = stack.item_instance_id else {
3482 return;
3483 };
3484 if Some(iid) == grant_id {
3485 return;
3486 }
3487 if stack.props.get("enchantable").map(String::as_str) == Some("0") {
3488 return;
3489 }
3490 if !grant_target_matches_mode(stack, mode) {
3491 return;
3492 }
3493 if !grant_tags_match(stack, &grant_tags) {
3494 return;
3495 }
3496 let name = stack
3497 .display_name
3498 .clone()
3499 .unwrap_or_else(|| stack.template_id.clone());
3500 let bindings = if stack.status_bindings.is_empty() {
3501 String::new()
3502 } else {
3503 format!(
3504 " · {}",
3505 stack
3506 .status_bindings
3507 .iter()
3508 .map(|b| b.effect_id.as_str())
3509 .collect::<Vec<_>>()
3510 .join(", ")
3511 )
3512 };
3513 out.push(GrantTargetOption {
3514 label: format!("{where_label}: {name}{bindings}"),
3515 target_instance_id: iid,
3516 });
3517 };
3518 fn walk(
3519 stacks: &[flatland_protocol::ItemStack],
3520 where_label: &str,
3521 push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
3522 ) {
3523 for s in stacks {
3524 push(s, where_label);
3525 if !s.contents.is_empty() {
3526 let nested = format!(
3527 "{where_label}/{}",
3528 s.display_name.as_deref().unwrap_or(s.template_id.as_str())
3529 );
3530 walk(&s.contents, &nested, push);
3531 }
3532 }
3533 }
3534 walk(&self.inventory_stacks, "Bag", &mut push);
3535 for (slot, stack) in &self.worn {
3536 push(stack, body_slot_label(*slot));
3537 let nest = format!(
3538 "{}/{}",
3539 body_slot_label(*slot),
3540 stack
3541 .display_name
3542 .as_deref()
3543 .unwrap_or(stack.template_id.as_str())
3544 );
3545 walk(&stack.contents, &nest, &mut push);
3546 }
3547 out
3548 }
3549
3550 pub fn item_base_mass(&self, template_id: &str) -> f32 {
3551 self.inventory_hints
3552 .get(template_id)
3553 .and_then(|h| h.base_mass)
3554 .unwrap_or(0.5)
3555 }
3556
3557 pub fn item_base_volume(&self, template_id: &str) -> f32 {
3558 self.inventory_hints
3559 .get(template_id)
3560 .and_then(|h| h.base_volume)
3561 .unwrap_or(1.0)
3562 }
3563
3564 pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
3565 let unit = stack
3566 .base_mass
3567 .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
3568 unit * stack.quantity as f32
3569 }
3570
3571 fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
3572 let unit = stack.base_volume.unwrap_or(1.0);
3573 unit * stack.quantity as f32
3574 + stack
3575 .contents
3576 .iter()
3577 .map(Self::stack_tree_volume)
3578 .sum::<f32>()
3579 }
3580
3581 fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
3582 contents.iter().map(Self::stack_tree_volume).sum()
3583 }
3584
3585 fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
3586 self.inventory_hints
3587 .get(template_id)
3588 .and_then(|h| h.capacity_volume)
3589 .filter(|c| *c > 0.0)
3590 }
3591
3592 fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
3593 stack
3594 .capacity_volume
3595 .filter(|c| *c > 0.0)
3596 .or_else(|| self.template_capacity_volume(&stack.template_id))
3597 }
3598
3599 pub fn container_volume_label(&self, row: &InventoryRow) -> String {
3601 let Some((used, cap)) = self.container_volume_stats(row) else {
3602 return String::new();
3603 };
3604 let free = (cap - used).max(0.0);
3605 format!(" vol {used:.0}/{cap:.0} ({free:.0} free)")
3606 }
3607
3608 fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
3609 if row.is_chest_shell {
3610 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
3611 return None;
3612 };
3613 let chest = self
3614 .placed_containers
3615 .iter()
3616 .find(|c| c.id == *container_id)?;
3617 let cap = self
3618 .stack_capacity_volume(&row.stack)
3619 .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
3620 let used = if chest.accessible {
3621 Self::contents_used_volume(&chest.contents)
3622 } else {
3623 0.0
3624 };
3625 return Some((used, cap));
3626 }
3627
3628 let cap = self.stack_capacity_volume(&row.stack)?;
3629 let used = Self::contents_used_volume(&row.stack.contents);
3630 Some((used, cap))
3631 }
3632
3633 pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
3634 if row.is_chest_shell {
3635 return true;
3636 }
3637 if row.is_equip_shell {
3638 return self.inventory_item_category(&row.stack.template_id) == Some("container");
3639 }
3640 self.inventory_item_category(&row.stack.template_id) == Some("container")
3641 || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
3642 }
3643
3644 fn container_stack_for(
3645 &self,
3646 location: &flatland_protocol::InventoryLocation,
3647 parent_instance_id: Option<uuid::Uuid>,
3648 ) -> Option<flatland_protocol::ItemStack> {
3649 match location {
3650 flatland_protocol::InventoryLocation::Root => {
3651 let pid = parent_instance_id?;
3652 self.find_stack_by_instance(&self.inventory_stacks, pid)
3653 }
3654 flatland_protocol::InventoryLocation::Worn { slot } => {
3655 let worn = self.worn.get(slot)?;
3656 if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
3657 Some(worn.clone())
3658 } else {
3659 self.find_stack_by_instance(&worn.contents, parent_instance_id?)
3660 }
3661 }
3662 flatland_protocol::InventoryLocation::Placed { container_id } => {
3663 let chest = self
3664 .placed_containers
3665 .iter()
3666 .find(|c| c.id == *container_id)?;
3667 if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
3668 Some(flatland_protocol::ItemStack {
3669 template_id: chest.template_id.clone(),
3670 quantity: 1,
3671 item_instance_id: chest.item_instance_id,
3672 props: Default::default(),
3673 status_bindings: Vec::new(),
3674 contents: chest.contents.clone(),
3675 display_name: Some(chest.display_name.clone()),
3676 category: Some("container".into()),
3677 capacity_volume: self
3678 .inventory_hints
3679 .get(&chest.template_id)
3680 .and_then(|h| h.capacity_volume),
3681 worker_lodging_capacity: chest.worker_lodging_capacity,
3682 ..Default::default()
3683 })
3684 } else {
3685 self.find_stack_by_instance(&chest.contents, parent_instance_id?)
3686 }
3687 }
3688 flatland_protocol::InventoryLocation::Keychain => None,
3689 flatland_protocol::InventoryLocation::WhisperPouch => None,
3690 }
3691 }
3692
3693 fn find_stack_by_instance(
3694 &self,
3695 stacks: &[flatland_protocol::ItemStack],
3696 instance_id: uuid::Uuid,
3697 ) -> Option<flatland_protocol::ItemStack> {
3698 for stack in stacks {
3699 if stack.item_instance_id == Some(instance_id) {
3700 return Some(stack.clone());
3701 }
3702 if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
3703 return Some(found);
3704 }
3705 }
3706 None
3707 }
3708
3709 pub fn max_movable_to(
3711 &self,
3712 template_id: &str,
3713 stack_qty: u32,
3714 from: &flatland_protocol::InventoryLocation,
3715 to: &flatland_protocol::InventoryLocation,
3716 parent_instance_id: Option<uuid::Uuid>,
3717 ) -> u32 {
3718 let unit_vol = self.item_base_volume(template_id);
3719 let mut limit = stack_qty;
3720
3721 if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
3722 let cap = parent
3723 .capacity_volume
3724 .or_else(|| {
3725 self.inventory_hints
3726 .get(&parent.template_id)
3727 .and_then(|h| h.capacity_volume)
3728 })
3729 .unwrap_or(0.0);
3730 if cap > 0.0 && unit_vol > 0.0 {
3731 let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
3732 limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
3733 }
3734 }
3735
3736 let _ = from;
3737 limit.max(0).min(stack_qty)
3738 }
3739
3740 pub fn move_picker_max_at_selection(&self) -> u32 {
3741 let Some(picker) = &self.move_picker else {
3742 return 1;
3743 };
3744 let Some(opt) = picker.options.get(self.move_picker_index) else {
3745 return picker.stack_quantity;
3746 };
3747 match &opt.kind {
3748 MoveOptionKind::Cancel
3749 | MoveOptionKind::Drop
3750 | MoveOptionKind::Use
3751 | MoveOptionKind::GrantApply
3752 | MoveOptionKind::SellPlotToCrown { .. }
3753 | MoveOptionKind::PickupPlaced { .. }
3754 | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
3755 MoveOptionKind::Move {
3756 location,
3757 parent_instance_id,
3758 } => self.max_movable_to(
3759 &picker.template_id,
3760 picker.stack_quantity,
3761 &picker.from,
3762 location,
3763 *parent_instance_id,
3764 ),
3765 }
3766 }
3767
3768 pub fn clamp_move_picker_quantity(&mut self) {
3769 let max = self.move_picker_max_at_selection();
3770 if let Some(picker) = &mut self.move_picker {
3771 if max == 0 {
3772 picker.quantity = 1;
3773 } else {
3774 picker.quantity = picker.quantity.clamp(1, max);
3775 }
3776 }
3777 }
3778
3779 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
3780 let max = self.move_picker_max_at_selection().max(1);
3781 if let Some(picker) = &mut self.move_picker {
3782 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3783 picker.quantity = next as u32;
3784 }
3785 }
3786
3787 pub fn move_picker_set_quantity_max(&mut self) {
3788 let max = self.move_picker_max_at_selection();
3789 if let Some(picker) = &mut self.move_picker {
3790 picker.quantity = if max == 0 {
3791 1
3792 } else {
3793 max.min(picker.stack_quantity)
3794 };
3795 }
3796 }
3797
3798 pub fn move_picker_set_quantity_min(&mut self) {
3799 if let Some(picker) = &mut self.move_picker {
3800 picker.quantity = 1;
3801 }
3802 }
3803
3804 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
3805 if let Some(picker) = &mut self.destroy_picker {
3806 let max = picker.stack_quantity.max(1);
3807 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3808 picker.quantity = next as u32;
3809 }
3810 }
3811
3812 pub fn destroy_picker_set_quantity_max(&mut self) {
3813 if let Some(picker) = &mut self.destroy_picker {
3814 picker.quantity = picker.stack_quantity.max(1);
3815 }
3816 }
3817
3818 pub fn destroy_picker_set_quantity_min(&mut self) {
3819 if let Some(picker) = &mut self.destroy_picker {
3820 picker.quantity = 1;
3821 }
3822 }
3823
3824 pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3825 let have = self.inventory.get(template_id).copied().unwrap_or(0);
3826 (have, have >= need)
3827 }
3828
3829 pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3831 let have = self
3832 .plot_build_offer
3833 .as_ref()
3834 .and_then(|o| {
3835 o.available
3836 .iter()
3837 .find(|s| s.template_id == template_id)
3838 .map(|s| s.quantity)
3839 })
3840 .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
3841 (have, have >= need)
3842 }
3843
3844 pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3845 self.building_materials
3846 .iter()
3847 .filter(|m| m.can_wall)
3848 .collect()
3849 }
3850
3851 pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3852 self.building_materials
3853 .iter()
3854 .filter(|m| m.can_roof)
3855 .collect()
3856 }
3857
3858 pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3859 self.plot_build_wall_options()
3860 .get(self.plot_build_wall_index)
3861 .copied()
3862 }
3863
3864 pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3865 self.plot_build_roof_options()
3866 .get(self.plot_build_roof_index)
3867 .copied()
3868 }
3869
3870 pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
3872 let Some(wall) = self.plot_build_selected_wall() else {
3873 return Vec::new();
3874 };
3875 let Some(roof) = self.plot_build_selected_roof() else {
3876 return Vec::new();
3877 };
3878 let area = self
3879 .plot_build_offer
3880 .as_ref()
3881 .filter(|o| o.pad_ok)
3882 .map(|o| o.pad_width_m * o.pad_depth_m)
3883 .unwrap_or(0.0);
3884 if area <= 0.0 {
3885 return Vec::new();
3886 }
3887 let mut map: std::collections::HashMap<String, (String, u32)> =
3888 std::collections::HashMap::new();
3889 for line in &wall.wall_bom {
3890 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3891 if qty == 0 {
3892 continue;
3893 }
3894 let name = if line.display_name.is_empty() {
3895 line.template_id.clone()
3896 } else {
3897 line.display_name.clone()
3898 };
3899 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3900 entry.1 = entry.1.saturating_add(qty);
3901 }
3902 for line in &roof.roof_bom {
3903 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3904 if qty == 0 {
3905 continue;
3906 }
3907 let name = if line.display_name.is_empty() {
3908 line.template_id.clone()
3909 } else {
3910 line.display_name.clone()
3911 };
3912 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3913 entry.1 = entry.1.saturating_add(qty);
3914 }
3915 let mut out: Vec<_> = map
3916 .into_iter()
3917 .map(|(id, (name, qty))| (id, name, qty))
3918 .collect();
3919 out.sort_by(|a, b| a.0.cmp(&b.0));
3920 out
3921 }
3922
3923 pub fn plot_build_duration_secs(&self) -> Option<f32> {
3924 let wall = self.plot_build_selected_wall()?;
3925 let roof = self.plot_build_selected_roof()?;
3926 let offer = self.plot_build_offer.as_ref()?;
3927 if !offer.pad_ok {
3928 return None;
3929 }
3930 let area = offer.pad_width_m * offer.pad_depth_m;
3931 let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
3932 let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
3933 Some(ticks.max(2.0) / 30.0)
3934 }
3935
3936 pub fn plot_build_can_afford(&self) -> bool {
3937 if self.plot_build_offer.as_ref().is_none_or(|o| !o.pad_ok) {
3938 return false;
3939 }
3940 self.plot_build_bom_lines()
3941 .iter()
3942 .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
3943 }
3944
3945 pub fn currency_display(&self) -> String {
3946 crate::currency::currency_line(&self.inventory)
3947 }
3948
3949 pub fn in_shallow_water(&self) -> bool {
3951 let (px, py) = self.player_position();
3952 self.terrain_at(px, py)
3953 .is_some_and(|k| k == TerrainKindView::ShallowWater)
3954 }
3955
3956 pub fn near_liquid_fill_source(&self) -> bool {
3958 let (px, py) = self.player_position();
3959 const CELL: f32 = 1.0;
3960 let offsets = [
3961 (0.0, 0.0),
3962 (CELL, 0.0),
3963 (-CELL, 0.0),
3964 (0.0, CELL),
3965 (0.0, -CELL),
3966 ];
3967 for (dx, dy) in offsets {
3968 if matches!(
3969 self.terrain_at(px + dx, py + dy),
3970 Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
3971 ) {
3972 return true;
3973 }
3974 }
3975 self.buildings.iter().any(|b| {
3976 if !b.tags.iter().any(|t| t == "well") {
3977 return false;
3978 }
3979 let hw = b.width_m * 0.5;
3980 let hd = b.depth_m * 0.5;
3981 let nx = px.clamp(b.x - hw, b.x + hw);
3982 let ny = py.clamp(b.y - hd, b.y + hd);
3983 let dx = px - nx;
3984 let dy = py - ny;
3985 dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
3986 })
3987 }
3988
3989 pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
3990 self.terrain_zone_at(x, y).map(|z| z.kind)
3991 }
3992
3993 pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
3995 use std::cell::RefCell;
3996
3997 const CHUNK: i32 = 8;
3998 thread_local! {
3999 static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
4000 RefCell::new(None);
4001 }
4002
4003 let zones = &self.terrain_zones;
4004 if zones.is_empty() {
4005 return None;
4006 }
4007 if zones.len() <= 48 {
4008 return zones
4009 .iter()
4010 .enumerate()
4011 .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
4012 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
4013 .map(|(_, z)| z);
4014 }
4015
4016 let ptr = zones.as_ptr();
4017 let len = zones.len();
4018 INDEX.with(|cell| {
4019 let mut slot = cell.borrow_mut();
4020 let stale = match slot.as_ref() {
4021 Some((p, l, _)) => *p != ptr || *l != len,
4022 None => true,
4023 };
4024 if stale {
4025 let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
4026 std::collections::HashMap::new();
4027 for (zi, z) in zones.iter().enumerate() {
4028 let x0 = z.x0.min(z.x1).floor() as i32;
4029 let y0 = z.y0.min(z.y1).floor() as i32;
4030 let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
4031 let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
4032 let cx0 = x0.div_euclid(CHUNK);
4033 let cy0 = y0.div_euclid(CHUNK);
4034 let cx1 = x1.div_euclid(CHUNK);
4035 let cy1 = y1.div_euclid(CHUNK);
4036 for cy in cy0..=cy1 {
4037 for cx in cx0..=cx1 {
4038 chunks.entry((cx, cy)).or_default().push(zi);
4039 }
4040 }
4041 }
4042 *slot = Some((ptr, len, chunks));
4043 }
4044 let chunks = &slot.as_ref().expect("index").2;
4045 let cx = (x.floor() as i32).div_euclid(CHUNK);
4046 let cy = (y.floor() as i32).div_euclid(CHUNK);
4047 let mut best: Option<(usize, &TerrainZoneView)> = None;
4048 if let Some(list) = chunks.get(&(cx, cy)) {
4049 for &zi in list {
4050 let Some(z) = zones.get(zi) else { continue };
4051 if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
4052 continue;
4053 }
4054 best = match best {
4055 None => Some((zi, z)),
4056 Some((bi, bz)) => {
4057 if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
4058 Some((zi, z))
4059 } else {
4060 Some((bi, bz))
4061 }
4062 }
4063 };
4064 }
4065 }
4066 best.map(|(_, z)| z)
4067 })
4068 }
4069
4070 pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
4072 self.terrain_zone_at(x, y)
4073 .map(|z| z.elevation)
4074 .unwrap_or(0.0)
4075 }
4076
4077 pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
4079 const TOL: f32 = 0.35;
4080 let mut levels = vec![self.elevation_at(x, y)];
4081 for p in &self.z_platforms {
4082 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4083 levels.push(p.z);
4084 }
4085 }
4086 levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
4087 levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
4088 levels
4089 }
4090
4091 pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
4092 const TOL: f32 = 0.35;
4093 self.walkable_levels_at(x, y)
4094 .iter()
4095 .any(|&l| (l - z).abs() <= TOL)
4096 }
4097
4098 pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
4099 let mut top = self.elevation_at(x, y);
4100 for p in &self.z_platforms {
4101 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4102 top = top.max(p.z);
4103 }
4104 }
4105 top
4106 }
4107
4108 pub fn effective_inside_building(&self) -> Option<String> {
4110 self.player_entity().and_then(|p| p.inside_building.clone())
4111 }
4112
4113 pub fn placed_container_in_current_space(
4117 &self,
4118 c: &flatland_protocol::PlacedContainerView,
4119 ) -> bool {
4120 match (
4121 self.effective_inside_building().as_deref(),
4122 c.building_id.as_deref(),
4123 ) {
4124 (None, None) => true,
4125 (Some(a), Some(b)) => a == b,
4126 _ => false,
4127 }
4128 }
4129
4130 fn merge_stack_catalog_hints(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4131 fn walk(
4132 stacks: &[flatland_protocol::ItemStack],
4133 hints: &mut std::collections::HashMap<String, InventoryHint>,
4134 ) {
4135 for stack in stacks {
4136 if stack.display_name.is_some()
4137 || stack.category.is_some()
4138 || stack.base_mass.is_some()
4139 || stack.base_volume.is_some()
4140 || stack.base_value_copper.is_some()
4141 {
4142 hints.insert(
4143 stack.template_id.clone(),
4144 InventoryHint {
4145 display_name: stack
4146 .display_name
4147 .clone()
4148 .unwrap_or_else(|| stack.template_id.clone()),
4149 category: stack.category.clone().unwrap_or_default(),
4150 base_mass: stack.base_mass,
4151 base_volume: stack.base_volume,
4152 capacity_volume: stack.capacity_volume,
4153 stackable: stack.stackable.unwrap_or(true),
4154 listable: stack.listable.unwrap_or_else(|| {
4155 category_default_listable(stack.category.as_deref().unwrap_or(""))
4156 }),
4157 base_value_copper: stack.base_value_copper,
4158 },
4159 );
4160 }
4161 walk(&stack.contents, hints);
4162 }
4163 }
4164 walk(stacks, &mut self.inventory_hints);
4165 }
4166
4167 pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4168 self.inventory_stacks = stacks.to_vec();
4169 self.inventory.clear();
4170 self.inventory_hints.clear();
4171 fn walk(
4172 stacks: &[flatland_protocol::ItemStack],
4173 inventory: &mut std::collections::HashMap<String, u32>,
4174 hints: &mut std::collections::HashMap<String, InventoryHint>,
4175 ) {
4176 for stack in stacks {
4177 *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
4178 if stack.display_name.is_some()
4179 || stack.category.is_some()
4180 || stack.base_mass.is_some()
4181 || stack.base_volume.is_some()
4182 || stack.base_value_copper.is_some()
4183 {
4184 hints.insert(
4185 stack.template_id.clone(),
4186 InventoryHint {
4187 display_name: stack
4188 .display_name
4189 .clone()
4190 .unwrap_or_else(|| stack.template_id.clone()),
4191 category: stack.category.clone().unwrap_or_default(),
4192 base_mass: stack.base_mass,
4193 base_volume: stack.base_volume,
4194 capacity_volume: stack.capacity_volume,
4195 stackable: stack.stackable.unwrap_or(true),
4196 listable: stack.listable.unwrap_or_else(|| {
4197 category_default_listable(stack.category.as_deref().unwrap_or(""))
4198 }),
4199 base_value_copper: stack.base_value_copper,
4200 },
4201 );
4202 }
4203 walk(&stack.contents, inventory, hints);
4204 }
4205 }
4206 walk(stacks, &mut self.inventory, &mut self.inventory_hints);
4207 for item in self.worn.values() {
4209 walk(
4210 std::slice::from_ref(item),
4211 &mut self.inventory,
4212 &mut self.inventory_hints,
4213 );
4214 }
4215 }
4216
4217 fn sync_item_catalog(&mut self, entries: &[ItemCatalogEntryView]) {
4218 if entries.is_empty() {
4219 return;
4220 }
4221 self.item_catalog.clear();
4222 self.item_catalog.reserve(entries.len());
4223 for entry in entries {
4224 if entry.template_id.is_empty() {
4225 continue;
4226 }
4227 self.item_catalog
4228 .insert(entry.template_id.clone(), entry.clone());
4229 }
4230 }
4231
4232 fn remove_carried_instance(&mut self, instance_id: uuid::Uuid, quantity: Option<u32>) {
4236 fn take_from(
4237 stacks: &mut Vec<flatland_protocol::ItemStack>,
4238 instance_id: uuid::Uuid,
4239 qty: Option<u32>,
4240 ) -> bool {
4241 if let Some(i) = stacks
4242 .iter()
4243 .position(|s| s.item_instance_id == Some(instance_id))
4244 {
4245 let have = stacks[i].quantity;
4246 let take = qty.unwrap_or(have).min(have);
4247 if take >= have {
4248 stacks.remove(i);
4249 } else {
4250 stacks[i].quantity = have - take;
4251 }
4252 return true;
4253 }
4254 stacks
4255 .iter_mut()
4256 .any(|stack| take_from(&mut stack.contents, instance_id, qty))
4257 }
4258
4259 if take_from(&mut self.inventory_stacks, instance_id, quantity) {
4260 let stacks = self.inventory_stacks.clone();
4261 self.sync_inventory_from_stacks(&stacks);
4262 self.refresh_inventory_ui();
4263 return;
4264 }
4265 let slots: Vec<_> = self.worn.keys().copied().collect();
4266 for slot in slots {
4267 let Some(item) = self.worn.get_mut(&slot) else {
4268 continue;
4269 };
4270 if take_from(&mut item.contents, instance_id, quantity) {
4271 let stacks = self.inventory_stacks.clone();
4272 self.sync_inventory_from_stacks(&stacks);
4273 self.refresh_inventory_ui();
4274 return;
4275 }
4276 }
4277 }
4278
4279 pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
4282 if notice.message.starts_with("Gave ") {
4286 if notice.coins_delta != 0 {
4287 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4288 let stacks = self.inventory_stacks.clone();
4289 self.sync_inventory_from_stacks(&stacks);
4290 }
4291 self.record_shop_trade_notice(notice);
4292 return;
4293 }
4294 let subtract_items =
4295 notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
4296 for stack in ¬ice.inventory_delta {
4297 if stack.quantity == 0 {
4298 continue;
4299 }
4300 if subtract_items {
4301 crate::currency::drain_template_stacks(
4302 &mut self.inventory_stacks,
4303 &stack.template_id,
4304 stack.quantity,
4305 );
4306 continue;
4307 }
4308 let stackable = self
4309 .inventory_hints
4310 .get(&stack.template_id)
4311 .map(|h| h.stackable)
4312 .or(stack.stackable)
4313 .unwrap_or(true);
4314 if stackable {
4315 if let Some(existing) = self
4316 .inventory_stacks
4317 .iter_mut()
4318 .find(|s| s.template_id == stack.template_id)
4319 {
4320 existing.quantity = existing.quantity.saturating_add(stack.quantity);
4321 if stack.display_name.is_some() {
4322 existing.display_name = stack.display_name.clone();
4323 }
4324 if stack.category.is_some() {
4325 existing.category = stack.category.clone();
4326 }
4327 continue;
4328 }
4329 }
4330 self.inventory_stacks.push(stack.clone());
4331 }
4332 if notice.coins_delta != 0 {
4333 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4334 }
4335 if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
4336 let stacks = self.inventory_stacks.clone();
4337 self.sync_inventory_from_stacks(&stacks);
4338 }
4339 self.record_shop_trade_notice(notice);
4340 }
4341
4342 pub fn worn_rows(&self) -> Vec<InventoryRow> {
4347 let mut rows = Vec::new();
4348 for (slot, item) in &self.worn {
4349 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4350 rows.push(InventoryRow {
4351 depth: 0,
4352 stack: item.clone(),
4353 from: from.clone(),
4354 from_parent_instance_id: None,
4355 is_equip_shell: true,
4356 is_chest_shell: false,
4357 section: InventorySection::Worn,
4358 });
4359 for child in &item.contents {
4360 push_inventory_rows(
4361 &mut rows,
4362 1,
4363 child,
4364 &from,
4365 item.item_instance_id,
4366 InventorySection::Worn,
4367 );
4368 }
4369 }
4370 rows
4371 }
4372
4373 pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
4375 let equipped = self.hand_equipped_instance_ids();
4376 self.inventory_stacks
4377 .iter()
4378 .filter(|s| s.item_instance_id.is_some_and(|id| !equipped.contains(&id)))
4379 .collect()
4380 }
4381
4382 pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
4384 let equipped = self.hand_equipped_instance_ids();
4385 self.inventory_stacks
4386 .iter()
4387 .filter_map(|stack| {
4388 let item_instance_id = stack.item_instance_id?;
4389 if equipped.contains(&item_instance_id) {
4390 return None;
4391 }
4392 let label = stack
4393 .display_name
4394 .clone()
4395 .unwrap_or_else(|| stack.template_id.clone());
4396 let label = if stack.quantity > 1 {
4397 format!("{label} ×{}", stack.quantity)
4398 } else {
4399 label
4400 };
4401 Some(WorkerGiveOption {
4402 item_instance_id,
4403 label,
4404 quantity: stack.quantity,
4405 template_id: stack.template_id.clone(),
4406 })
4407 })
4408 .collect()
4409 }
4410
4411 pub fn teachable_blueprint_options(
4413 &self,
4414 worker: &flatland_protocol::HiredWorkerView,
4415 ) -> Vec<WorkerTeachOption> {
4416 let copper = crate::currency::copper_from_counts(&self.inventory);
4417 let mut options: Vec<WorkerTeachOption> = self
4418 .blueprints
4419 .iter()
4420 .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
4421 .map(|bp| {
4422 let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
4423 let cost = bp.worker_train_copper;
4424 WorkerTeachOption {
4425 blueprint_id: bp.id.clone(),
4426 label: if bp.label.is_empty() {
4427 bp.id.clone()
4428 } else {
4429 bp.label.clone()
4430 },
4431 cost_copper: cost,
4432 min_level,
4433 worker_level: worker.level,
4434 can_afford: copper >= cost,
4435 level_ok: worker.level >= min_level,
4436 }
4437 })
4438 .collect();
4439 options.sort_by(|a, b| a.label.cmp(&b.label));
4440 options
4441 }
4442
4443 pub fn person_rows(&self) -> Vec<InventoryRow> {
4446 self.person_rows_filtered("")
4447 }
4448
4449 pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4450 let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
4451 roots.sort_by(|a, b| {
4452 let ca = a
4453 .category
4454 .as_deref()
4455 .or_else(|| self.inventory_item_category(&a.template_id))
4456 .unwrap_or("");
4457 let cb = b
4458 .category
4459 .as_deref()
4460 .or_else(|| self.inventory_item_category(&b.template_id))
4461 .unwrap_or("");
4462 let ga = inventory_category_group(ca).1;
4463 let gb = inventory_category_group(cb).1;
4464 ga.cmp(&gb).then_with(|| {
4465 let na = a.display_name.as_deref().unwrap_or(a.template_id.as_str());
4466 let nb = b.display_name.as_deref().unwrap_or(b.template_id.as_str());
4467 na.cmp(nb)
4468 })
4469 });
4470 let mut rows = Vec::new();
4471 for stack in roots {
4472 push_inventory_rows_filtered(
4473 &mut rows,
4474 0,
4475 stack,
4476 &flatland_protocol::InventoryLocation::Root,
4477 None,
4478 InventorySection::Person,
4479 filter,
4480 );
4481 }
4482 rows
4483 }
4484
4485 pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4486 if filter.is_empty() {
4487 return self.worn_rows();
4488 }
4489 let mut rows = Vec::new();
4490 for (slot, item) in &self.worn {
4491 if !stack_matches_filter(item, filter) {
4492 continue;
4493 }
4494 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4495 let self_hit = {
4496 let f = filter.to_ascii_lowercase();
4497 let name = item
4498 .display_name
4499 .as_deref()
4500 .unwrap_or("")
4501 .to_ascii_lowercase();
4502 let tid = item.template_id.to_ascii_lowercase();
4503 name.contains(&f) || tid.contains(&f)
4504 };
4505 rows.push(InventoryRow {
4506 depth: 0,
4507 stack: item.clone(),
4508 from: from.clone(),
4509 from_parent_instance_id: None,
4510 is_equip_shell: true,
4511 is_chest_shell: false,
4512 section: InventorySection::Worn,
4513 });
4514 for child in &item.contents {
4515 if self_hit || stack_matches_filter(child, filter) {
4516 push_inventory_rows_filtered(
4517 &mut rows,
4518 1,
4519 child,
4520 &from,
4521 item.item_instance_id,
4522 InventorySection::Worn,
4523 if self_hit { "" } else { filter },
4524 );
4525 }
4526 }
4527 }
4528 rows
4529 }
4530
4531 pub fn carried_worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4535 let mut rows = Vec::new();
4536 for (slot, item) in &self.worn {
4537 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4538 for child in &item.contents {
4539 push_inventory_rows_filtered(
4540 &mut rows,
4541 0,
4542 child,
4543 &from,
4544 item.item_instance_id,
4545 InventorySection::Person,
4546 filter,
4547 );
4548 }
4549 }
4550 rows
4551 }
4552
4553 pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
4555 let mut rows = self.worn_rows();
4556 rows.extend(self.person_rows());
4557 rows.into_iter().map(|r| (r.depth, r.stack)).collect()
4558 }
4559
4560 pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
4564 let (px, py) = self.player_position();
4565 let mut list: Vec<NearbyContainer> = self
4566 .placed_containers
4567 .iter()
4568 .filter(|c| self.placed_container_in_current_space(c))
4569 .filter_map(|c| {
4570 let distance_m = (c.x - px).hypot(c.y - py);
4571 if distance_m > CONTAINER_RANGE_M {
4572 return None;
4573 }
4574 let mut rows = Vec::new();
4575 let from = flatland_protocol::InventoryLocation::Placed {
4576 container_id: c.id.clone(),
4577 };
4578 rows.push(InventoryRow {
4579 depth: 0,
4580 stack: flatland_protocol::ItemStack {
4581 template_id: c.template_id.clone(),
4582 quantity: 1,
4583 item_instance_id: c.item_instance_id,
4584 props: Default::default(),
4585 status_bindings: Vec::new(),
4586 contents: Vec::new(),
4587 display_name: Some(c.display_name.clone()),
4588 category: Some("container".into()),
4589 capacity_volume: c.capacity_volume,
4590 worker_lodging_capacity: c.worker_lodging_capacity,
4591 ..Default::default()
4592 },
4593 from: from.clone(),
4594 from_parent_instance_id: None,
4595 is_equip_shell: false,
4596 is_chest_shell: true,
4597 section: InventorySection::Nearby,
4598 });
4599 if c.accessible {
4600 for child in &c.contents {
4601 push_inventory_rows(
4602 &mut rows,
4603 1,
4604 child,
4605 &from,
4606 c.item_instance_id,
4607 InventorySection::Nearby,
4608 );
4609 }
4610 }
4611 Some(NearbyContainer {
4612 view: c.clone(),
4613 distance_m,
4614 rows,
4615 })
4616 })
4617 .collect();
4618 list.sort_by(|a, b| {
4619 a.distance_m
4620 .partial_cmp(&b.distance_m)
4621 .unwrap_or(std::cmp::Ordering::Equal)
4622 });
4623 list
4624 }
4625
4626 pub fn nearest_placed_container(
4628 &self,
4629 max_dist: f32,
4630 ) -> Option<flatland_protocol::PlacedContainerView> {
4631 let (px, py) = self.player_position();
4632 self.placed_containers
4633 .iter()
4634 .filter(|c| self.placed_container_in_current_space(c))
4635 .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
4636 .min_by(|a, b| {
4637 let da = (a.x - px).hypot(a.y - py);
4638 let db = (b.x - px).hypot(b.y - py);
4639 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
4640 })
4641 .cloned()
4642 }
4643
4644 pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
4647 let filter = self.inventory_filter.as_str();
4648 match self.inventory_tab {
4649 InventoryTab::OnPerson => {
4650 let mut rows = self.carried_worn_rows_filtered(filter);
4651 rows.extend(self.person_rows_filtered(filter));
4652 rows
4653 }
4654 InventoryTab::Nearby => {
4655 let mut rows = Vec::new();
4656 for nc in self.nearby_containers() {
4657 if filter.is_empty() {
4658 rows.extend(nc.rows);
4659 continue;
4660 }
4661 let shell = nc.rows.first().cloned();
4662 let contents: Vec<_> = nc
4663 .rows
4664 .iter()
4665 .skip(1)
4666 .filter(|r| stack_matches_filter(&r.stack, filter))
4667 .cloned()
4668 .collect();
4669 let shell_hit = shell
4670 .as_ref()
4671 .map(|s| stack_matches_filter(&s.stack, filter))
4672 .unwrap_or(false);
4673 if shell_hit || !contents.is_empty() {
4674 if let Some(s) = shell {
4675 rows.push(s);
4676 }
4677 if shell_hit {
4678 rows.extend(nc.rows.into_iter().skip(1));
4679 } else {
4680 rows.extend(contents);
4681 }
4682 }
4683 }
4684 rows
4685 }
4686 }
4687 }
4688
4689 pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
4690 self.inventory_selectable_rows()
4691 .into_iter()
4692 .nth(self.inventory_menu_index)
4693 }
4694
4695 fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
4696 let cat = self
4697 .inventory_item_category(&row.stack.template_id)
4698 .unwrap_or("");
4699 if cat == "key" {
4700 self.key_inventory_label(&row.stack)
4701 } else {
4702 row.stack
4703 .display_name
4704 .clone()
4705 .unwrap_or_else(|| row.stack.template_id.clone())
4706 }
4707 }
4708
4709 fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
4711 let bindings =
4712 format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4713 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4714 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4715 let mode = Self::grant_mode(&row.stack);
4716 format!(" [grant {effect} · {mode} — e apply]")
4717 } else {
4718 String::new()
4719 };
4720 let qty = if row.stack.quantity > 1 {
4721 format!(" ×{}", row.stack.quantity)
4722 } else {
4723 String::new()
4724 };
4725 let worn_slot = if row.is_equip_shell {
4726 match row.from {
4727 flatland_protocol::InventoryLocation::Worn { slot } => {
4728 format!(" ({})", body_slot_label(slot))
4729 }
4730 _ => String::new(),
4731 }
4732 } else {
4733 String::new()
4734 };
4735 format!("{grant_hint}{bindings}{qty}{worn_slot}")
4736 }
4737
4738 fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
4739 (
4740 row.stack.template_id.clone(),
4741 self.inventory_row_base_label(row),
4742 self.inventory_row_visible_mod_signature(row),
4743 )
4744 }
4745
4746 fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
4748 let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
4749 for row in self.inventory_selectable_rows() {
4750 if row.stack.item_instance_id.is_none() {
4751 continue;
4752 }
4753 let key = self.inventory_row_instance_identity_key(&row);
4754 *counts.entry(key).or_default() += 1;
4755 }
4756 counts
4757 .into_iter()
4758 .filter(|(_, n)| *n > 1)
4759 .map(|(k, _)| k)
4760 .collect()
4761 }
4762
4763 fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
4764 let hex: String = id
4765 .as_simple()
4766 .to_string()
4767 .chars()
4768 .filter(|c| c.is_ascii_hexdigit())
4769 .collect();
4770 let short = if hex.len() >= 4 {
4771 &hex[hex.len() - 4..]
4772 } else {
4773 hex.as_str()
4774 };
4775 format!("Instance {id} (#{short})")
4776 }
4777
4778 pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
4780 let cat = self
4781 .inventory_item_category(&row.stack.template_id)
4782 .unwrap_or("");
4783 let label = self.inventory_row_base_label(row);
4784 let hint: String = if row.is_equip_shell {
4785 " [worn — Enter to unequip]".into()
4786 } else if row.is_chest_shell {
4787 let (locked, lodging_note) = match &row.from {
4788 flatland_protocol::InventoryLocation::Placed { container_id } => {
4789 let locked = self
4790 .placed_containers
4791 .iter()
4792 .find(|c| c.id == *container_id)
4793 .map(|c| c.locked)
4794 .unwrap_or(false);
4795 let lodging_note = self
4796 .lodging_occupancy_label(container_id)
4797 .map(|who| format!(" [lodging: {who}]"))
4798 .unwrap_or_default();
4799 (locked, lodging_note)
4800 }
4801 _ => (false, String::new()),
4802 };
4803 if locked {
4804 format!(" [locked — Enter pick up · l unlock]{lodging_note}")
4805 } else {
4806 format!(" [Enter pick up · l lock]{lodging_note}")
4807 }
4808 } else if cat == "key" {
4809 self.key_inventory_hint(&row.stack)
4810 } else {
4811 match cat {
4812 "weapon" => " [weapon]".into(),
4813 "container" => " [bag/chest/belt]".into(),
4814 "lodging" => " [worker lodging]".into(),
4815 "armor" => " [armor]".into(),
4816 _ => String::new(),
4817 }
4818 };
4819 let qty = if row.stack.quantity > 1 {
4820 format!(" ×{}", row.stack.quantity)
4821 } else {
4822 String::new()
4823 };
4824 let bindings =
4825 format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4826 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4827 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4828 let mode = Self::grant_mode(&row.stack);
4829 format!(" [grant {effect} · {mode} — e apply]")
4830 } else {
4831 String::new()
4832 };
4833 let mass = self.stack_mass(&row.stack);
4834 let mass_kg = (mass >= 0.05).then_some(mass);
4835 let mass_str = mass_kg.map(|m| format!(" {m:.1} kg")).unwrap_or_default();
4836 let volume = self.container_volume_stats(row);
4837 let vol_str = self.container_volume_label(row);
4838
4839 let mut title = label.clone();
4840 title.push_str(&qty);
4841 if row.is_equip_shell {
4842 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
4843 title.push_str(&format!(" ({})", body_slot_label(slot)));
4844 }
4845 }
4846
4847 InventoryRowView {
4848 depth: row.depth,
4849 text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
4850 title: format!("{title}{grant_hint}{bindings}"),
4851 mass_kg,
4852 volume,
4853 instance_tooltip: None,
4854 }
4855 }
4856
4857 fn push_browser_item(
4858 &self,
4859 lines: &mut Vec<InventoryBrowserLine>,
4860 row: &InventoryRow,
4861 global_idx: &mut usize,
4862 target: usize,
4863 highlight: bool,
4864 ambiguous_instance_keys: &HashSet<(String, String, String)>,
4865 ) {
4866 let mut view = self.format_inventory_row(row);
4867 if let Some(id) = row.stack.item_instance_id {
4868 let key = self.inventory_row_instance_identity_key(row);
4869 if ambiguous_instance_keys.contains(&key) {
4870 view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
4871 }
4872 }
4873 lines.push(InventoryBrowserLine::Item {
4874 selectable_index: *global_idx,
4875 selected: highlight && *global_idx == target,
4876 depth: view.depth,
4877 text: view.text,
4878 title: view.title,
4879 mass_kg: view.mass_kg,
4880 volume: view.volume,
4881 instance_tooltip: view.instance_tooltip,
4882 });
4883 *global_idx += 1;
4884 }
4885
4886 pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
4889 let mut lines = Vec::new();
4890 let target = self.inventory_menu_index;
4891 let highlight = !self.show_move_picker && !self.show_grant_picker;
4892 let filter = self.inventory_filter.as_str();
4893 let mut global_idx = 0usize;
4894 let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
4895
4896 match self.inventory_tab {
4897 InventoryTab::OnPerson => {
4898 lines.push(InventoryBrowserLine::Section("— In carried bags —".into()));
4899 let carried = self.carried_worn_rows_filtered(filter);
4900 if carried.is_empty() {
4901 lines.push(InventoryBrowserLine::Hint(
4902 " (no items in carried bags)".into(),
4903 ));
4904 } else {
4905 for row in &carried {
4906 self.push_browser_item(
4907 &mut lines,
4908 row,
4909 &mut global_idx,
4910 target,
4911 highlight,
4912 &ambiguous_instance_keys,
4913 );
4914 }
4915 }
4916
4917 lines.push(InventoryBrowserLine::Blank);
4918 lines.push(InventoryBrowserLine::Section(
4919 "— On you (loose, not worn) —".into(),
4920 ));
4921 let person = self.person_rows_filtered(filter);
4922 if person.is_empty() {
4923 lines.push(InventoryBrowserLine::Hint(" (empty)".into()));
4924 } else {
4925 let mut last_group: Option<&'static str> = None;
4926 for row in &person {
4927 if row.depth == 0 {
4928 let cat = row
4929 .stack
4930 .category
4931 .as_deref()
4932 .or_else(|| self.inventory_item_category(&row.stack.template_id))
4933 .unwrap_or("");
4934 let (group, _) = inventory_category_group(cat);
4935 if last_group != Some(group) {
4936 lines.push(InventoryBrowserLine::SlotLabel(format!(" {group}")));
4937 last_group = Some(group);
4938 }
4939 }
4940 self.push_browser_item(
4941 &mut lines,
4942 row,
4943 &mut global_idx,
4944 target,
4945 highlight,
4946 &ambiguous_instance_keys,
4947 );
4948 }
4949 }
4950 }
4951 InventoryTab::Nearby => {
4952 let nearby = self.nearby_containers();
4953 if nearby.is_empty() {
4954 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
4955 lines.push(InventoryBrowserLine::Hint(
4956 " (none within reach — walk up to a chest)".into(),
4957 ));
4958 lines.push(InventoryBrowserLine::Hint(
4959 " Select an on-person item, then m / Enter → move into chest.".into(),
4960 ));
4961 } else {
4962 let mut any_visible = false;
4963 for nc in &nearby {
4964 let shell = nc.rows.first();
4965 let contents: Vec<&InventoryRow> = if filter.is_empty() {
4966 nc.rows.iter().skip(1).collect()
4967 } else {
4968 let shell_hit = shell
4969 .map(|s| {
4970 let f = filter.to_ascii_lowercase();
4971 let name = s
4972 .stack
4973 .display_name
4974 .as_deref()
4975 .unwrap_or("")
4976 .to_ascii_lowercase();
4977 let tid = s.stack.template_id.to_ascii_lowercase();
4978 name.contains(&f) || tid.contains(&f)
4979 })
4980 .unwrap_or(false);
4981 if shell_hit {
4982 nc.rows.iter().skip(1).collect()
4983 } else {
4984 nc.rows
4985 .iter()
4986 .skip(1)
4987 .filter(|r| stack_matches_filter(&r.stack, filter))
4988 .collect()
4989 }
4990 };
4991 let shell_visible = filter.is_empty()
4992 || shell
4993 .map(|s| stack_matches_filter(&s.stack, filter))
4994 .unwrap_or(false)
4995 || !contents.is_empty();
4996 if !shell_visible && shell.is_some() {
4997 continue;
4998 }
4999 any_visible = true;
5000 lines.push(InventoryBrowserLine::Blank);
5001 let lock_note = if nc.view.locked && nc.view.accessible {
5002 " unlocked with your key"
5003 } else if nc.view.locked {
5004 " locked"
5005 } else {
5006 ""
5007 };
5008 lines.push(InventoryBrowserLine::Section(format!(
5009 "— {} ({:.0}m away){lock_note} —",
5010 nc.view.display_name, nc.distance_m
5011 )));
5012 if !nc.view.accessible {
5013 lines.push(InventoryBrowserLine::Hint(
5014 " locked — need the matching key (l to try)".into(),
5015 ));
5016 } else if nc.rows.is_empty() {
5017 lines.push(InventoryBrowserLine::Hint(
5018 " (empty — switch to On person, select an item, m to move in)"
5019 .into(),
5020 ));
5021 } else if let Some(shell_row) = shell {
5022 self.push_browser_item(
5023 &mut lines,
5024 shell_row,
5025 &mut global_idx,
5026 target,
5027 highlight,
5028 &ambiguous_instance_keys,
5029 );
5030 for row in contents {
5031 self.push_browser_item(
5032 &mut lines,
5033 row,
5034 &mut global_idx,
5035 target,
5036 highlight,
5037 &ambiguous_instance_keys,
5038 );
5039 }
5040 }
5041 }
5042 if !any_visible {
5043 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
5044 lines.push(InventoryBrowserLine::Hint(
5045 " (no matching items — clear filter with Esc)".into(),
5046 ));
5047 }
5048 }
5049 }
5050 }
5051 lines
5052 }
5053
5054 pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
5056 let mut opts = Vec::new();
5057 opts.push(MoveOption {
5058 label: "Relocate…".into(),
5059 kind: MoveOptionKind::RelocatePlaced {
5060 container_id: container_id.to_string(),
5061 },
5062 });
5063 opts.push(MoveOption {
5064 label: "On your person (loose)".into(),
5065 kind: MoveOptionKind::PickupPlaced {
5066 container_id: container_id.to_string(),
5067 nest_location: flatland_protocol::InventoryLocation::Root,
5068 nest_parent_instance_id: None,
5069 },
5070 });
5071 for (slot, item) in &self.worn {
5072 if item.category.as_deref() != Some("container") {
5073 continue;
5074 }
5075 if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
5076 continue;
5077 }
5078 let Some(parent_id) = item.item_instance_id else {
5079 continue;
5080 };
5081 let shell_name = item
5082 .display_name
5083 .clone()
5084 .unwrap_or_else(|| item.template_id.clone());
5085 opts.push(MoveOption {
5086 label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
5087 kind: MoveOptionKind::PickupPlaced {
5088 container_id: container_id.to_string(),
5089 nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
5090 nest_parent_instance_id: Some(parent_id),
5091 },
5092 });
5093 Self::append_chest_pickup_nested(
5095 &mut opts,
5096 container_id,
5097 flatland_protocol::InventoryLocation::Worn { slot: *slot },
5098 item,
5099 &format!("in {shell_name}"),
5100 );
5101 }
5102 opts.push(MoveOption {
5103 label: "Cancel".into(),
5104 kind: MoveOptionKind::Cancel,
5105 });
5106 opts
5107 }
5108
5109 fn append_chest_pickup_nested(
5110 opts: &mut Vec<MoveOption>,
5111 container_id: &str,
5112 location: flatland_protocol::InventoryLocation,
5113 parent: &flatland_protocol::ItemStack,
5114 context: &str,
5115 ) {
5116 for child in &parent.contents {
5117 if child.category.as_deref() != Some("container") {
5118 continue;
5119 }
5120 if !Self::is_volume_container_stack(child) {
5121 continue;
5122 }
5123 if child.world_placeable == Some(true) {
5125 continue;
5126 }
5127 let Some(child_id) = child.item_instance_id else {
5128 continue;
5129 };
5130 let name = child
5131 .display_name
5132 .clone()
5133 .unwrap_or_else(|| child.template_id.clone());
5134 opts.push(MoveOption {
5135 label: format!("{name} ({context})"),
5136 kind: MoveOptionKind::PickupPlaced {
5137 container_id: container_id.to_string(),
5138 nest_location: location.clone(),
5139 nest_parent_instance_id: Some(child_id),
5140 },
5141 });
5142 Self::append_chest_pickup_nested(
5143 opts,
5144 container_id,
5145 location.clone(),
5146 child,
5147 &format!("in {name}"),
5148 );
5149 }
5150 }
5151
5152 pub fn move_destinations_for(
5154 &self,
5155 from: &flatland_protocol::InventoryLocation,
5156 from_parent_instance_id: Option<uuid::Uuid>,
5157 moving_instance_id: Option<uuid::Uuid>,
5158 moving_template_id: &str,
5159 ) -> Vec<MoveOption> {
5160 let mut opts = Vec::new();
5161 if *from != flatland_protocol::InventoryLocation::Root {
5162 opts.push(MoveOption {
5163 label: "On your person (loose)".into(),
5164 kind: MoveOptionKind::Move {
5165 location: flatland_protocol::InventoryLocation::Root,
5166 parent_instance_id: None,
5167 },
5168 });
5169 }
5170 for (slot, item) in &self.worn {
5171 if item.category.as_deref() != Some("container") {
5172 continue;
5173 }
5174 let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
5175 let shell_name = item
5176 .display_name
5177 .clone()
5178 .unwrap_or_else(|| item.template_id.clone());
5179
5180 if *slot != BodySlot::Waist
5182 && item.item_instance_id != moving_instance_id
5183 && Self::is_volume_container_stack(item)
5184 {
5185 Self::push_move_destination(
5186 &mut opts,
5187 format!("{shell_name} (worn {})", body_slot_label(*slot)),
5188 location.clone(),
5189 item.item_instance_id,
5190 from,
5191 from_parent_instance_id,
5192 );
5193 }
5194
5195 if *slot == BodySlot::Waist
5197 && Self::attaches_to_belt_loop(moving_template_id)
5198 && item.item_instance_id != moving_instance_id
5199 {
5200 Self::push_move_destination(
5201 &mut opts,
5202 format!("{shell_name} (belt loop)"),
5203 location.clone(),
5204 item.item_instance_id,
5205 from,
5206 from_parent_instance_id,
5207 );
5208 }
5209
5210 let context = if *slot == BodySlot::Waist {
5211 format!("on {shell_name}")
5212 } else {
5213 format!("in {shell_name}")
5214 };
5215 Self::append_nested_container_destinations(
5216 &mut opts,
5217 location,
5218 item,
5219 &context,
5220 from,
5221 from_parent_instance_id,
5222 moving_instance_id,
5223 );
5224 }
5225 for nc in self.nearby_containers() {
5226 if !nc.view.accessible {
5227 continue;
5228 }
5229 let location = flatland_protocol::InventoryLocation::Placed {
5230 container_id: nc.view.id.clone(),
5231 };
5232 Self::push_move_destination(
5233 &mut opts,
5234 format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
5235 location,
5236 nc.view.item_instance_id,
5237 from,
5238 from_parent_instance_id,
5239 );
5240 }
5241 let allow_drop = moving_instance_id
5242 .map(|id| !self.hand_equipped_instance_ids().contains(&id))
5243 .unwrap_or(true)
5244 && moving_instance_id
5245 .and_then(|id| self.stack_for_instance(id))
5246 .map(|stack| {
5247 !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
5248 })
5249 .unwrap_or(
5250 moving_template_id != KEY_TEMPLATE
5251 && moving_template_id != PROPERTY_DEED_TEMPLATE,
5252 );
5253 if allow_drop {
5254 opts.push(MoveOption {
5255 label: "Drop on the ground".into(),
5256 kind: MoveOptionKind::Drop,
5257 });
5258 }
5259 opts.push(MoveOption {
5260 label: "Cancel".into(),
5261 kind: MoveOptionKind::Cancel,
5262 });
5263 opts
5264 }
5265
5266 fn is_same_container_dest(
5267 dest_location: &flatland_protocol::InventoryLocation,
5268 dest_parent: Option<uuid::Uuid>,
5269 from: &flatland_protocol::InventoryLocation,
5270 from_parent: Option<uuid::Uuid>,
5271 ) -> bool {
5272 dest_location == from && dest_parent == from_parent
5273 }
5274
5275 fn push_move_destination(
5276 opts: &mut Vec<MoveOption>,
5277 label: String,
5278 location: flatland_protocol::InventoryLocation,
5279 parent_instance_id: Option<uuid::Uuid>,
5280 from: &flatland_protocol::InventoryLocation,
5281 from_parent_instance_id: Option<uuid::Uuid>,
5282 ) {
5283 if Self::is_same_container_dest(
5284 &location,
5285 parent_instance_id,
5286 from,
5287 from_parent_instance_id,
5288 ) {
5289 return;
5290 }
5291 opts.push(MoveOption {
5292 label,
5293 kind: MoveOptionKind::Move {
5294 location,
5295 parent_instance_id,
5296 },
5297 });
5298 }
5299
5300 fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
5301 stack.capacity_volume.is_some_and(|c| c > 0.0)
5302 }
5303
5304 fn attaches_to_belt_loop(template_id: &str) -> bool {
5305 matches!(template_id, "leather_pouch" | "dimensional_pouch")
5306 }
5307
5308 fn append_nested_container_destinations(
5309 opts: &mut Vec<MoveOption>,
5310 location: flatland_protocol::InventoryLocation,
5311 container: &flatland_protocol::ItemStack,
5312 context: &str,
5313 from: &flatland_protocol::InventoryLocation,
5314 from_parent_instance_id: Option<uuid::Uuid>,
5315 moving_instance_id: Option<uuid::Uuid>,
5316 ) {
5317 for child in &container.contents {
5318 if Self::is_volume_container_stack(child)
5319 && child.item_instance_id != moving_instance_id
5320 {
5321 let name = child
5322 .display_name
5323 .clone()
5324 .unwrap_or_else(|| child.template_id.clone());
5325 Self::push_move_destination(
5326 opts,
5327 format!("{name} ({context})"),
5328 location.clone(),
5329 child.item_instance_id,
5330 from,
5331 from_parent_instance_id,
5332 );
5333 }
5334 let nested_context = format!(
5335 "in {}",
5336 child.display_name.as_deref().unwrap_or(&child.template_id)
5337 );
5338 Self::append_nested_container_destinations(
5339 opts,
5340 location.clone(),
5341 child,
5342 &nested_context,
5343 from,
5344 from_parent_instance_id,
5345 moving_instance_id,
5346 );
5347 }
5348 }
5349
5350 fn clamp_inventory_indices(&mut self) {
5351 let n = self.inventory_selectable_rows().len();
5352 self.inventory_menu_index = if n == 0 {
5353 0
5354 } else {
5355 self.inventory_menu_index.min(n - 1)
5356 };
5357 if let Some(picker) = &self.move_picker {
5358 let pn = picker.options.len();
5359 self.move_picker_index = if pn == 0 {
5360 0
5361 } else {
5362 self.move_picker_index.min(pn - 1)
5363 };
5364 }
5365 }
5366
5367 fn sync_interior_map_context(&mut self) {
5372 if self.effective_inside_building().is_none() {
5373 self.interior_map = None;
5374 if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
5375 self.z_platforms = platforms;
5376 self.z_transitions = transitions;
5377 }
5378 return;
5379 }
5380 self.sync_interior_z_bands();
5381 }
5382
5383 fn sync_interior_z_bands(&mut self) {
5385 if self.effective_inside_building().is_some() {
5386 if let Some(map) = &self.interior_map {
5387 if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
5388 if self.z_bands_outdoor_backup.is_none() {
5389 self.z_bands_outdoor_backup = Some((
5390 std::mem::take(&mut self.z_platforms),
5391 std::mem::take(&mut self.z_transitions),
5392 ));
5393 }
5394 self.z_platforms = map.z_platforms.clone();
5395 self.z_transitions = map.z_transitions.clone();
5396 }
5397 }
5398 }
5399 }
5400
5401 fn apply_snapshot_fields(
5402 &mut self,
5403 snapshot: &flatland_protocol::Snapshot,
5404 entity_id: EntityId,
5405 ) {
5406 self.tick = snapshot.tick;
5407 self.chunk_rev = snapshot.chunk_rev;
5408 self.content_rev = snapshot.content_rev;
5409 self.publish_rev = snapshot.publish_rev;
5410 self.resource_nodes = snapshot.resource_nodes.clone();
5411 self.ground_drops = snapshot.ground_drops.clone();
5412 self.placed_containers = snapshot.placed_containers.clone();
5413 self.world_x0 = snapshot.world_x0;
5414 self.world_y0 = snapshot.world_y0;
5415 self.world_width_m = snapshot.world_width_m;
5416 self.world_height_m = snapshot.world_height_m;
5417 self.world_clock = snapshot.world_clock;
5418 self.terrain_zones = snapshot.terrain_zones.clone();
5419 self.z_platforms = snapshot.z_platforms.clone();
5420 self.z_transitions = snapshot.z_transitions.clone();
5421 self.z_bands_outdoor_backup = None;
5423 self.buildings = snapshot.buildings.clone();
5424 self.doors = snapshot.doors.clone();
5425 self.interior_map = snapshot.interior_map.clone();
5426 self.npcs = snapshot.npcs.clone();
5427 self.blueprints = snapshot.blueprints.clone();
5428 self.building_materials = snapshot.building_materials.clone();
5429 self.sync_inventory_from_stacks(&snapshot.inventory);
5430 self.player = snapshot
5431 .entities
5432 .iter()
5433 .find(|e| e.id == entity_id)
5434 .cloned();
5435 self.entities = snapshot.entities.clone();
5436 self.quest_log = snapshot.quest_log.clone();
5437 self.apply_hired_workers(snapshot.hired_workers.clone());
5438 self.interactables = snapshot.interactables.clone();
5439 self.ledger = snapshot.ledger.clone();
5440 self.career = snapshot.career.clone();
5441 self.combat_fx = snapshot.combat_fx.clone();
5442 self.ground_hazards = snapshot.ground_hazards.clone();
5443 self.property_zones = snapshot.property_zones.clone();
5444 self.tax_zones = snapshot.tax_zones.clone();
5445 self.growth_zones = snapshot.growth_zones.clone();
5446 self.biome_zones = snapshot.biome_zones.clone();
5447 self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
5448 self.property_plots = snapshot.property_plots.clone();
5449 self.property_plot_settings = snapshot.property_plot_settings.clone();
5450 self.sync_item_catalog(&snapshot.item_catalog);
5451 if self.effective_inside_building().is_some() {
5454 self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
5455 }
5456 self.sync_interior_map_context();
5457 self.refresh_whisper_range();
5458 self.sync_gameplay_audio();
5459 }
5460
5461 fn refresh_inventory_ui(&mut self) {
5465 if let Some(picker) = &self.move_picker {
5466 let instance_id = picker.item_instance_id;
5467 let still_exists = self
5468 .inventory_selectable_rows()
5469 .iter()
5470 .any(|r| r.stack.item_instance_id == Some(instance_id));
5471 if !still_exists {
5472 self.move_picker = None;
5473 self.show_move_picker = false;
5474 }
5475 }
5476 if let Some(picker) = &self.destroy_picker {
5477 let instance_id = picker.item_instance_id;
5478 let still_exists = self
5479 .inventory_selectable_rows()
5480 .iter()
5481 .any(|r| r.stack.item_instance_id == Some(instance_id));
5482 if !still_exists {
5483 self.destroy_picker = None;
5484 self.show_destroy_picker = false;
5485 self.destroy_confirm_pending = false;
5486 }
5487 }
5488 self.clamp_inventory_indices();
5489 }
5490
5491 fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
5497 let selected_id = self
5498 .hired_workers
5499 .get(self.workers_menu_index)
5500 .map(|w| w.instance_id.clone());
5501 let previous_worker_ids: HashSet<String> = self
5502 .hired_workers
5503 .iter()
5504 .map(|worker| worker.instance_id.clone())
5505 .collect();
5506 workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
5507 let now = Instant::now();
5508 let saw_new_worker = workers
5509 .iter()
5510 .any(|worker| !previous_worker_ids.contains(&worker.instance_id));
5511 for worker in &workers {
5512 let was_hit = self
5513 .hired_workers
5514 .iter()
5515 .find(|previous| previous.instance_id == worker.instance_id)
5516 .is_some_and(|previous| {
5517 matches!(worker.mode, flatland_protocol::WorkerModeView::Defender)
5518 && worker.vitals.health_pct + 0.01 < previous.vitals.health_pct
5519 });
5520 if was_hit {
5521 self.worker_health_ring_until
5522 .insert(worker.entity_id, now + WORKER_HEALTH_RING_HOLD);
5523 }
5524 }
5525 let worker_entity_ids: HashSet<EntityId> =
5526 workers.iter().map(|worker| worker.entity_id).collect();
5527 self.worker_health_ring_until
5528 .retain(|entity_id, _| worker_entity_ids.contains(entity_id));
5529 for w in &workers {
5530 let prev_err = self
5531 .hired_workers
5532 .iter()
5533 .find(|p| p.instance_id == w.instance_id)
5534 .and_then(|p| p.last_error.as_deref());
5535 let new_err = w.last_error.as_deref();
5536 if new_err != prev_err {
5537 if let Some(err) = new_err {
5538 if !worker_error_is_transient(err) {
5539 self.push_log(format!("Worker {}: {err}", w.label));
5540 }
5541 }
5542 }
5543 }
5544 let mut next_display = BTreeMap::new();
5545 let mut next_errors = BTreeMap::new();
5546 for w in &workers {
5547 let mut sticky = self
5548 .worker_step_display
5549 .remove(&w.instance_id)
5550 .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
5551 sticky.observe(&w.step_label, now);
5552 next_display.insert(w.instance_id.clone(), sticky);
5553
5554 let mut err_sticky = self
5555 .worker_error_display
5556 .remove(&w.instance_id)
5557 .unwrap_or_default();
5558 err_sticky.observe(w.last_error.as_deref(), now);
5559 if err_sticky.shown(now).is_some() {
5560 next_errors.insert(w.instance_id.clone(), err_sticky);
5561 }
5562 }
5563 self.worker_step_display = next_display;
5564 self.worker_error_display = next_errors;
5565 self.hired_workers = workers;
5566 if saw_new_worker {
5567 self.pending_worker_hire_since = None;
5568 }
5569 self.sync_worker_take_picker_from_hired();
5570 if let Some(id) = selected_id {
5571 if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
5572 self.workers_menu_index = idx;
5573 return;
5574 }
5575 }
5576 if self.workers_menu_index >= self.hired_workers.len() {
5577 self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
5578 }
5579 }
5580
5581 fn sync_worker_take_picker_from_hired(&mut self) {
5583 if !self.show_worker_take_picker {
5584 return;
5585 }
5586 let Some(picker) = self.worker_take_picker.clone() else {
5587 return;
5588 };
5589 let Some(worker) = self
5590 .hired_workers
5591 .iter()
5592 .find(|w| w.instance_id == picker.worker_instance_id)
5593 .cloned()
5594 else {
5595 self.show_worker_take_picker = false;
5596 self.worker_take_picker = None;
5597 self.worker_take_picker_index = 0;
5598 return;
5599 };
5600 let options: Vec<WorkerGiveOption> = worker
5601 .inventory
5602 .iter()
5603 .filter_map(|stack| {
5604 let item_instance_id = stack.item_instance_id?;
5605 let label = stack
5606 .display_name
5607 .clone()
5608 .unwrap_or_else(|| stack.template_id.clone());
5609 let label = if stack.quantity > 1 {
5610 format!("{label} ×{}", stack.quantity)
5611 } else {
5612 label
5613 };
5614 Some(WorkerGiveOption {
5615 item_instance_id,
5616 label,
5617 quantity: stack.quantity,
5618 template_id: stack.template_id.clone(),
5619 })
5620 })
5621 .collect();
5622 if options.is_empty() {
5623 self.show_worker_take_picker = false;
5624 self.worker_take_picker = None;
5625 self.worker_take_picker_index = 0;
5626 return;
5627 }
5628 let prev_id = picker
5629 .options
5630 .get(self.worker_take_picker_index)
5631 .map(|o| o.item_instance_id);
5632 let idx = prev_id
5633 .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
5634 .unwrap_or(0)
5635 .min(options.len().saturating_sub(1));
5636 let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
5637 let quantity = picker.quantity.clamp(1, max_qty);
5638 self.worker_take_picker_index = idx;
5639 self.worker_take_picker = Some(WorkerTakePicker {
5640 worker_instance_id: picker.worker_instance_id,
5641 worker_label: picker.worker_label,
5642 options,
5643 quantity,
5644 });
5645 }
5646
5647 pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
5649 self.worker_step_display
5650 .get(worker_instance_id)
5651 .map(|s| s.shown.as_str())
5652 .or_else(|| {
5653 self.hired_workers
5654 .iter()
5655 .find(|w| w.instance_id == worker_instance_id)
5656 .map(|w| w.step_label.as_str())
5657 })
5658 .unwrap_or("")
5659 }
5660
5661 pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
5663 let now = Instant::now();
5664 self.worker_error_display
5665 .get(worker_instance_id)
5666 .and_then(|s| s.shown(now))
5667 .or_else(|| {
5668 self.hired_workers
5669 .iter()
5670 .find(|w| w.instance_id == worker_instance_id)
5671 .and_then(|w| w.last_error.as_deref())
5672 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
5673 })
5674 .filter(|e| !worker_error_is_hud_noise(e))
5675 }
5676
5677 fn apply_combat_hud(&mut self, combat: &CombatHud) {
5678 self.in_combat = combat.in_combat;
5679 self.auto_attack = combat.auto_attack;
5680 self.combat_has_los = combat.has_los;
5681 self.attack_cd_ticks = combat.attack_cd_ticks;
5682 self.gcd_ticks = combat.gcd_ticks;
5683 self.weapon_ability_id = combat.ability_id.clone();
5684 self.mainhand_template_id = combat.mainhand_template_id.clone();
5685 self.mainhand_label = combat.mainhand_label.clone();
5686 self.mainhand_instance_id = combat.mainhand_instance_id;
5687 self.offhand_template_id = combat.offhand_template_id.clone();
5688 self.offhand_label = combat.offhand_label.clone();
5689 self.offhand_instance_id = combat.offhand_instance_id;
5690 self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
5691 1
5692 } else {
5693 combat.mainhand_hand_slots
5694 };
5695 self.defense = combat.defense.clone();
5696 self.worn = combat.worn.iter().cloned().collect();
5697 self.carry_mass = combat.carry_mass;
5698 self.carry_mass_max = combat.carry_mass_max;
5699 self.encumbrance = combat.encumbrance;
5700 self.move_speed_mps = combat.move_speed_mps;
5701 self.move_speed_mult = combat.move_speed_mult;
5702 self.cast_progress = combat.cast.clone();
5703 self.timed_channel = combat.timed_channel.clone();
5704 if self.active_craft_channel().is_none() {
5705 self.craft_channel_blueprint_id = None;
5706 }
5707 self.plot_build_offer = combat.plot_build.clone();
5708 self.ability_cooldowns = combat.ability_cooldowns.clone();
5709 self.blocking_active = combat.blocking_active;
5710 self.max_target_slots = combat.max_target_slots.max(1);
5711 self.combat_slots = combat.slots.clone();
5712 self.rotation_presets = combat.rotation_presets.clone();
5713 self.known_abilities = combat.known_abilities.clone();
5714 self.ability_meta = combat
5715 .ability_meta
5716 .iter()
5717 .cloned()
5718 .map(|meta| (meta.id.clone(), meta))
5719 .collect();
5720 self.ability_mastery = combat
5721 .ability_mastery
5722 .iter()
5723 .cloned()
5724 .map(|row| (row.ability_id.clone(), row))
5725 .collect();
5726 self.hotbar = combat.hotbar.clone();
5727 self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
5728 self.keychain_stacks = combat.keychain.clone();
5729 self.whisper_pouch_stacks = combat.whisper_pouch.clone();
5730 self.combat_target_detail = combat.target.clone();
5731 self.statuses = combat.statuses.clone();
5732 self.combat_target = combat.target_entity_id;
5733 if combat.progression_xp_base > 0.0 {
5734 self.progression_curve = Some(flatland_protocol::ProgressionCurve {
5735 baseline_display: combat.progression_baseline,
5736 xp_base: combat.progression_xp_base,
5737 xp_growth: combat.progression_xp_growth,
5738 });
5739 }
5740 if let Some(xp) = &combat.progression_xp {
5741 if let Some(player) = &mut self.player {
5742 player.progression_xp = Some(xp.clone());
5743 if let Some(attrs) = combat.attributes {
5744 player.attributes = Some(attrs);
5745 }
5746 if let Some(skills) = &combat.skills {
5747 player.skills = Some(skills.clone());
5748 }
5749 }
5750 }
5751 if let Some(label) = &combat.target_label {
5752 self.combat_target_label = Some(label.clone());
5753 } else if let Some(id) = combat.target_entity_id {
5754 self.combat_target_label = self
5755 .entities
5756 .iter()
5757 .find(|e| e.id == id)
5758 .map(|e| e.label.clone())
5759 .or_else(|| self.combat_target_label.clone());
5760 }
5761 self.refresh_inventory_ui();
5762 }
5763
5764 pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
5766 self.combat_slots
5767 .iter()
5768 .find(|s| s.slot_index == slot)
5769 .and_then(|s| s.target_entity_id)
5770 .or_else(|| if slot == 1 { self.combat_target } else { None })
5771 }
5772
5773 pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
5775 self.ability_meta
5776 .get(ability_id)
5777 .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
5778 .unwrap_or(self.ground_target.is_some())
5781 }
5782
5783 pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
5785 self.ability_meta
5786 .get(ability_id)
5787 .map(|meta| meta.aim_mode == "ground")
5788 .unwrap_or(false)
5789 }
5790
5791 pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
5794 self.ability_meta
5795 .get(ability_id)
5796 .map(|meta| meta.auto_rotation_eligible)
5797 .unwrap_or(true)
5798 }
5799
5800 pub fn set_ground_target(&mut self, x: f32, y: f32) {
5802 self.ground_target = Some((x, y, 0.0));
5803 }
5804
5805 pub fn clear_ground_target(&mut self) {
5807 self.ground_target = None;
5808 }
5809
5810 pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
5813 if !(1..=9).contains(&slot_1_to_9) {
5814 return None;
5815 }
5816 self.hotbar
5817 .get((slot_1_to_9 - 1) as usize)
5818 .and_then(|a| a.as_deref())
5819 .filter(|id| !id.is_empty())
5820 }
5821
5822 pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
5824 let binding = self.hotbar_ability(slot_1_to_9)?;
5825 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
5826 let name = self
5827 .inventory_hints
5828 .get(template_id)
5829 .map(|h| h.display_name.as_str())
5830 .unwrap_or(template_id);
5831 let qty = self.inventory.get(template_id).copied().unwrap_or(0);
5832 Some(format!("{name}×{qty}"))
5833 } else {
5834 Some(binding.to_string())
5835 }
5836 }
5837
5838 pub fn loadout_ability_choices(&self) -> Vec<String> {
5840 let mut out = self.known_abilities.clone();
5841 let weapon = self.weapon_ability_id.trim();
5842 if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
5843 out.push(weapon.to_string());
5844 }
5845 out
5846 }
5847
5848 pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
5850 let mut out = Vec::new();
5851 for ability in self.loadout_ability_choices() {
5852 let meta = if ability == self.weapon_ability_id {
5853 Some("weapon".into())
5854 } else {
5855 None
5856 };
5857 out.push(LoadoutHotbarChoice {
5858 binding: ability.clone(),
5859 label: ability,
5860 meta,
5861 });
5862 }
5863 let mut consumables: Vec<(String, String, u32)> = Vec::new();
5864 for stack in &self.inventory_stacks {
5865 if Self::stack_is_item_grant(stack) {
5866 continue;
5867 }
5868 if Self::stack_is_blueprint_scroll(stack) {
5869 continue;
5870 }
5871 if self.inventory_item_category(&stack.template_id) != Some("consumable")
5872 && !Self::stack_is_serving(stack)
5873 {
5874 continue;
5875 }
5876 let qty = stack.quantity.max(1);
5877 if let Some((_, _, existing)) = consumables
5878 .iter_mut()
5879 .find(|(id, _, _)| id == &stack.template_id)
5880 {
5881 *existing = existing.saturating_add(qty);
5882 } else {
5883 let label = stack
5884 .display_name
5885 .clone()
5886 .or_else(|| {
5887 self.inventory_hints
5888 .get(&stack.template_id)
5889 .map(|h| h.display_name.clone())
5890 })
5891 .unwrap_or_else(|| stack.template_id.clone());
5892 consumables.push((stack.template_id.clone(), label, qty));
5893 }
5894 }
5895 consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
5896 for (template_id, label, qty) in consumables {
5897 out.push(LoadoutHotbarChoice {
5898 binding: flatland_protocol::hotbar_consumable_binding(&template_id),
5899 label: format!("{label} ×{qty}"),
5900 meta: Some("use".into()),
5901 });
5902 }
5903 out
5904 }
5905
5906 pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
5908 self.combat_candidates()
5909 }
5910
5911 pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
5913 let (px, py) = self.player_position();
5914 let dist = |id: EntityId| {
5915 self.entities
5916 .iter()
5917 .find(|e| e.id == id)
5918 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
5919 .unwrap_or(f32::MAX)
5920 };
5921
5922 let mut allies = Vec::new();
5923 if let Some(me) = self.player.as_ref() {
5925 let alive = me
5926 .vitals
5927 .as_ref()
5928 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
5929 .unwrap_or(true);
5930 if alive {
5931 allies.push((self.entity_id, "Yourself".into()));
5932 }
5933 }
5934 for entity in &self.entities {
5935 if entity.id == self.entity_id {
5936 continue;
5937 }
5938 if entity.vitals.is_some() {
5939 let alive = entity
5940 .vitals
5941 .as_ref()
5942 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
5943 .unwrap_or(true);
5944 if alive {
5945 allies.push((entity.id, entity.label.clone()));
5946 }
5947 }
5948 }
5949 allies.sort_by(|(a, _), (b, _)| {
5950 if *a == self.entity_id {
5951 return std::cmp::Ordering::Less;
5952 }
5953 if *b == self.entity_id {
5954 return std::cmp::Ordering::Greater;
5955 }
5956 dist(*a)
5957 .partial_cmp(&dist(*b))
5958 .unwrap_or(std::cmp::Ordering::Equal)
5959 });
5960
5961 let mut monsters = self.combat_candidates();
5962 monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
5963 allies.into_iter().chain(monsters).collect()
5964 }
5965
5966 fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
5967 match slot_index {
5968 2 => self.t2_candidates(),
5969 _ => self.t1_candidates(),
5970 }
5971 }
5972
5973 pub fn pick_combat_target_at(
5975 &self,
5976 wx: f32,
5977 wy: f32,
5978 slot_index: u8,
5979 radius_m: f32,
5980 ) -> Option<(EntityId, String)> {
5981 let mut best: Option<(f32, EntityId, String)> = None;
5982 for (id, label) in self.candidates_for_slot(slot_index) {
5983 let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
5984 if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
5986 let d = distance(wx, wy, npc.x, npc.y);
5987 if d <= radius_m {
5988 best = match best {
5989 Some((bd, _, _)) if bd <= d => best,
5990 _ => Some((d, id, label)),
5991 };
5992 }
5993 }
5994 continue;
5995 };
5996 let d = distance(
5997 wx,
5998 wy,
5999 entity.transform.position.x,
6000 entity.transform.position.y,
6001 );
6002 if d <= radius_m {
6003 best = match best {
6004 Some((bd, _, _)) if bd <= d => best,
6005 _ => Some((d, id, label)),
6006 };
6007 }
6008 }
6009 best.map(|(_, id, label)| (id, label))
6010 }
6011
6012 pub(crate) fn restore_from_welcome(
6014 &mut self,
6015 session_id: SessionId,
6016 entity_id: EntityId,
6017 snapshot: &flatland_protocol::Snapshot,
6018 ) {
6019 self.clear_harvest_state();
6020 self.disconnect_reason = None;
6021 self.show_stats = false;
6022 self.show_craft_menu = false;
6023 self.show_shop_menu = false;
6024 self.shop_catalog = None;
6025 self.show_inventory_menu = false;
6026 self.session_id = session_id;
6027 self.entity_id = entity_id;
6028 self.connected = true;
6029 self.apply_snapshot_fields(snapshot, entity_id);
6030 if let Some(combat) = &snapshot.combat {
6031 self.apply_combat_hud(combat);
6032 let stacks = self.inventory_stacks.clone();
6033 self.sync_inventory_from_stacks(&stacks);
6034 }
6035 }
6036
6037 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
6038 self.tick = delta.tick;
6039 self.world_clock = delta.world_clock;
6040
6041 if delta.entities.is_empty() {
6043 self.ground_drops = delta.ground_drops.clone();
6044 self.combat_fx = delta.combat_fx.clone();
6045 self.ground_hazards = delta.ground_hazards.clone();
6046 self.property_plots = delta.property_plots.clone();
6047 self.apply_terrain_overlays(&delta.terrain_overlays);
6048 if let Some(combat) = &delta.combat {
6049 self.apply_combat_hud(combat);
6050 let stacks = self.inventory_stacks.clone();
6051 self.sync_inventory_from_stacks(&stacks);
6052 }
6053 self.refresh_whisper_range();
6055 self.sync_gameplay_audio();
6056 return;
6057 }
6058 if !delta.buildings.is_empty() {
6059 self.buildings = delta.buildings.clone();
6060 }
6061 if !delta.blueprints.is_empty() {
6062 self.blueprints = delta.blueprints.clone();
6063 }
6064 if !delta.building_materials.is_empty() {
6065 self.building_materials = delta.building_materials.clone();
6066 }
6067 self.sync_inventory_from_stacks(&delta.inventory);
6068
6069 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
6070 self.player = Some(updated.clone());
6071 }
6072 self.entities = delta.entities.clone();
6073 if self.player.is_none() {
6074 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
6075 }
6076
6077 self.sync_interior_map_context();
6078
6079 if !delta.resource_nodes.is_empty() {
6083 self.resource_nodes = delta.resource_nodes.clone();
6084 } else if delta.interior_map.is_some() || self.effective_inside_building().is_some() {
6085 self.resource_nodes = delta.resource_nodes.clone();
6086 }
6087 self.ground_drops = delta.ground_drops.clone();
6088 self.placed_containers = delta.placed_containers.clone();
6090 if !delta.doors.is_empty() {
6091 self.doors = delta.doors.clone();
6092 }
6093 if self.effective_inside_building().is_some() {
6094 if let Some(map) = &delta.interior_map {
6095 self.interior_map = Some(map.clone());
6096 }
6097 } else {
6098 self.interior_map = None;
6099 }
6100 self.sync_interior_z_bands();
6101 self.npcs = delta.npcs.clone();
6103 if !delta.quest_log.is_empty() {
6104 self.quest_log = delta.quest_log.clone();
6105 }
6106 self.apply_hired_workers(delta.hired_workers.clone());
6107 if !delta.interactables.is_empty() {
6108 self.interactables = delta.interactables.clone();
6109 }
6110 if delta.ledger.is_some() {
6111 self.ledger = delta.ledger.clone();
6112 }
6113 if delta.career.is_some() {
6114 self.career = delta.career.clone();
6115 }
6116 self.combat_fx = delta.combat_fx.clone();
6117 self.ground_hazards = delta.ground_hazards.clone();
6118 if !delta.property_plots.is_empty() {
6120 self.property_plots = delta.property_plots.clone();
6121 }
6122 self.apply_terrain_overlays(&delta.terrain_overlays);
6123 if let Some(combat) = &delta.combat {
6124 self.apply_combat_hud(combat);
6125 let stacks = self.inventory_stacks.clone();
6126 self.sync_inventory_from_stacks(&stacks);
6127 } else {
6128 self.refresh_inventory_ui();
6129 }
6130 self.refresh_whisper_range();
6131 self.sync_gameplay_audio();
6132 }
6133
6134 fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
6137 self.terrain_zones.retain(|z| !z.id.starts_with("rt:"));
6138 self.terrain_zones.extend(overlays.iter().cloned());
6139 }
6140
6141 fn refresh_whisper_range(&mut self) {
6144 let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
6145 return;
6146 };
6147 let (px, py) = self.player_position();
6148 let in_range = self.entities.iter().any(|e| {
6149 e.id == peer
6150 && distance(px, py, e.transform.position.x, e.transform.position.y)
6151 <= INTERACTION_RADIUS_M
6152 });
6153 if !in_range {
6154 self.social_chat.cancel_whisper_out_of_range();
6155 }
6156 }
6157
6158 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
6160 let (px, py) = self.player_position();
6161 let mut out = Vec::new();
6162 for npc in &self.npcs {
6163 let Some(eid) = npc.entity_id else {
6164 continue;
6165 };
6166 let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
6167 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
6168 if alive && has_hp {
6169 out.push((eid, npc.label.clone()));
6170 }
6171 }
6172 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
6173 let dist = |id: EntityId| {
6174 self.entities
6175 .iter()
6176 .find(|e| e.id == id)
6177 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
6178 .unwrap_or(f32::MAX)
6179 };
6180 dist(*a_id)
6181 .partial_cmp(&dist(*b_id))
6182 .unwrap_or(std::cmp::Ordering::Equal)
6183 .then_with(|| a_label.cmp(b_label))
6184 .then_with(|| a_id.cmp(b_id))
6185 });
6186 out
6187 }
6188
6189 pub fn refresh_combat_target_label(&mut self) {
6190 let Some(id) = self.combat_target else {
6191 return;
6192 };
6193 if let Some((_, label)) = self
6194 .combat_candidates()
6195 .into_iter()
6196 .find(|(eid, _)| *eid == id)
6197 {
6198 self.combat_target_label = Some(label);
6199 } else if let Some(label) = self
6200 .entities
6201 .iter()
6202 .find(|e| e.id == id)
6203 .map(|e| e.label.clone())
6204 {
6205 self.combat_target_label = Some(label);
6206 }
6207 }
6208
6209 pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
6210 self.quest_log
6211 .iter()
6212 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
6213 .collect()
6214 }
6215
6216 pub fn has_worker_lodging(&self) -> bool {
6218 self.free_worker_lodging_slots() > 0
6219 }
6220
6221 pub fn free_worker_lodging_slots(&self) -> i64 {
6223 let slots: u32 = self
6224 .placed_containers
6225 .iter()
6226 .filter(|c| match (self.character_id, c.owner_character_id) {
6227 (Some(me), Some(owner)) => me == owner,
6228 (Some(_), None) => false,
6229 (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
6230 })
6231 .map(|c| c.worker_lodging_capacity.unwrap_or(0))
6232 .sum();
6233 let used = self.hired_workers.len() as u32;
6234 slots as i64 - used as i64
6235 }
6236
6237 pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
6239 let mut names: Vec<String> = self
6240 .hired_workers
6241 .iter()
6242 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
6243 .map(|w| w.label.clone())
6244 .collect();
6245 names.sort();
6246 names
6247 }
6248
6249 pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
6251 let is_lodging = self
6252 .placed_containers
6253 .iter()
6254 .find(|c| c.id == container_id)
6255 .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
6256 if !is_lodging {
6257 return None;
6258 }
6259 let names = self.lodging_occupant_labels(container_id);
6260 Some(if names.is_empty() {
6261 "vacant".into()
6262 } else {
6263 names.join(", ")
6264 })
6265 }
6266
6267 pub fn lodging_is_occupied(&self, container_id: &str) -> bool {
6269 matches!(
6270 self.lodging_occupancy_label(container_id),
6271 Some(label) if label != "vacant"
6272 )
6273 }
6274
6275 pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
6276 self.quest_log
6277 .iter()
6278 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
6279 .or_else(|| {
6280 self.quest_log
6281 .iter()
6282 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
6283 })
6284 }
6285
6286 pub fn nearby_lockable_door(&self) -> bool {
6288 let (px, py) = self.player_position();
6289 self.doors
6290 .iter()
6291 .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M)
6292 }
6293
6294 pub fn nearby_open_player_door(&self) -> bool {
6296 if self.effective_inside_building().is_some() {
6297 return false;
6298 }
6299 let (px, py) = self.player_position();
6300 self.doors.iter().any(|d| {
6301 if !d.open || d.locked {
6302 return false;
6303 }
6304 let player_house = self
6305 .buildings
6306 .iter()
6307 .find(|b| b.id == d.building_id)
6308 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6309 player_house && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M
6310 })
6311 }
6312
6313 pub fn nearby_player_exit_door(&self) -> bool {
6315 let Some(bid) = self.effective_inside_building() else {
6316 return false;
6317 };
6318 let (px, py) = self.player_position();
6319 self.doors.iter().any(|d| {
6320 if d.building_id != bid || d.portal.is_none() {
6321 return false;
6322 }
6323 let player_house = self
6324 .buildings
6325 .iter()
6326 .find(|b| b.id == d.building_id)
6327 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6328 player_house && (d.x - px).hypot(d.y - py) <= 1.5
6329 })
6330 }
6331
6332 pub fn nearest_interact_target(&self) -> Option<String> {
6334 let (px, py) = self.player_position();
6335 let inside = self.effective_inside_building();
6336
6337 #[derive(Clone, Copy, PartialEq, Eq)]
6338 enum Kind {
6339 Player,
6340 Npc,
6341 HiredWorker,
6342 QuestBoard,
6343 ExitDoor,
6344 EnterDoor,
6345 }
6346
6347 fn kind_class(kind: Kind) -> u8 {
6348 match kind {
6349 Kind::EnterDoor => 0,
6350 Kind::QuestBoard => 1,
6351 Kind::Player | Kind::Npc => 2,
6352 Kind::ExitDoor => 3,
6353 Kind::HiredWorker => 4,
6354 }
6355 }
6356
6357 fn kind_priority(kind: Kind) -> u8 {
6358 match kind {
6359 Kind::EnterDoor => 0,
6360 Kind::QuestBoard => 1,
6361 Kind::Player | Kind::Npc => 2,
6362 Kind::ExitDoor => 3,
6363 Kind::HiredWorker => 4,
6364 }
6365 }
6366
6367 let mut best: Option<(f32, Kind, String)> = None;
6368
6369 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
6370 if dist > max {
6371 return;
6372 }
6373 let replace = match best {
6374 None => true,
6375 Some((_, bk, _)) if kind_class(kind) < kind_class(bk) => true,
6376 Some((bd, bk, _))
6377 if kind_class(kind) == kind_class(bk) && dist < bd - 0.05 =>
6378 {
6379 true
6380 }
6381 Some((bd, bk, _))
6382 if kind_class(kind) == kind_class(bk) && (dist - bd).abs() <= 0.05 =>
6383 {
6384 kind_priority(kind) < kind_priority(bk)
6385 }
6386 _ => false,
6387 };
6388 if replace {
6389 best = Some((dist, kind, id));
6390 }
6391 };
6392
6393 for npc in &self.npcs {
6394 consider(
6395 distance(px, py, npc.x, npc.y),
6396 INTERACTION_RADIUS_M,
6397 Kind::Npc,
6398 npc.id.clone(),
6399 );
6400 }
6401
6402 for worker in &self.hired_workers {
6403 consider(
6404 distance(px, py, worker.x, worker.y),
6405 INTERACTION_RADIUS_M,
6406 Kind::HiredWorker,
6407 worker.instance_id.clone(),
6408 );
6409 }
6410
6411 for entity in &self.entities {
6412 if entity.id == self.entity_id
6413 || entity.vitals.is_none()
6414 || entity.label.trim().is_empty()
6415 {
6416 continue;
6417 }
6418 if self.hired_workers.iter().any(|w| w.entity_id == entity.id) {
6420 continue;
6421 }
6422 consider(
6423 distance(
6424 px,
6425 py,
6426 entity.transform.position.x,
6427 entity.transform.position.y,
6428 ),
6429 INTERACTION_RADIUS_M,
6430 Kind::Player,
6431 entity.id.to_string(),
6432 );
6433 }
6434
6435 for door in &self.doors {
6436 if let Some(ref bid) = inside {
6437 if door.building_id != *bid {
6438 continue;
6439 }
6440 let is_exit = door.portal.is_some();
6441 let max = if is_exit {
6442 INTERACTION_RADIUS_M
6443 } else {
6444 DOOR_INTERACTION_RADIUS_M
6445 };
6446 let kind = if is_exit {
6447 Kind::ExitDoor
6448 } else {
6449 Kind::EnterDoor
6450 };
6451 consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
6452 continue;
6453 }
6454 consider(
6455 distance(px, py, door.x, door.y),
6456 DOOR_INTERACTION_RADIUS_M,
6457 Kind::EnterDoor,
6458 door.id.clone(),
6459 );
6460 }
6461
6462 if inside.is_none() {
6463 for inter in &self.interactables {
6464 if inter.kind == "quest_board" {
6465 consider(
6466 distance(px, py, inter.x, inter.y),
6467 QUEST_BOARD_INTERACTION_RADIUS_M,
6468 Kind::QuestBoard,
6469 inter.id.clone(),
6470 );
6471 }
6472 }
6473 }
6474
6475 best.map(|(_, _, id)| id)
6476 }
6477
6478 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
6480 if self.effective_inside_building().is_some() {
6481 return None;
6482 }
6483 let (px, py) = self.player_position();
6484 self.interactables
6485 .iter()
6486 .filter(|i| i.kind == "quest_board")
6487 .map(|i| {
6488 let label = if i.label.is_empty() {
6489 "Quest board".to_string()
6490 } else {
6491 i.label.clone()
6492 };
6493 (label, distance(px, py, i.x, i.y))
6494 })
6495 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
6496 }
6497
6498 pub fn template_display_name(&self, template_id: &str) -> String {
6500 if let Some(name) = self
6501 .inventory_hints
6502 .get(template_id)
6503 .map(|h| h.display_name.clone())
6504 .filter(|n| !n.is_empty())
6505 {
6506 return name;
6507 }
6508 if let Some(entry) = self.item_catalog.get(template_id) {
6509 if !entry.display_name.trim().is_empty() {
6510 return entry.display_name.clone();
6511 }
6512 }
6513 humanize_template_id(template_id)
6514 }
6515
6516 pub fn catalog_entry(&self, template_id: &str) -> Option<&ItemCatalogEntryView> {
6517 self.item_catalog.get(template_id)
6518 }
6519
6520 pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
6522 if !display_name.is_empty() {
6523 display_name.to_string()
6524 } else {
6525 self.template_display_name(template_id)
6526 }
6527 }
6528
6529 pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
6530 self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
6531 }
6532
6533 pub fn blueprint_ingredient_label(
6534 &self,
6535 input: &flatland_protocol::BlueprintIngredientView,
6536 ) -> String {
6537 self.blueprint_item_label(&input.template_id, &input.display_name)
6538 }
6539
6540 pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
6541 self.blueprint_item_label(&tool.item, &tool.display_name)
6542 }
6543
6544 pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
6546 use crate::worker_route_editor::{
6547 node_candidates, node_candidates_stable, route_editor_lodging_anchor,
6548 };
6549 let lodging = self
6550 .worker_route_editor
6551 .as_ref()
6552 .and_then(|ed| ed.lodging_container_id.as_deref());
6553 match route_editor_lodging_anchor(lodging, &self.placed_containers) {
6554 Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
6555 None => node_candidates_stable(&self.resource_nodes),
6556 }
6557 }
6558
6559 pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
6560 if dist_m.is_nan() {
6561 return "—".into();
6562 }
6563 let from_bed = self
6564 .worker_route_editor
6565 .as_ref()
6566 .and_then(|ed| ed.lodging_container_id.as_deref())
6567 .and_then(|id| {
6568 self.placed_containers
6569 .iter()
6570 .find(|c| c.id == id)
6571 .map(|c| c.display_name.clone())
6572 });
6573 match from_bed {
6574 Some(bed) => format!("{dist_m:.0}m from {bed}"),
6575 None => format!("{dist_m:.0}m"),
6576 }
6577 }
6578
6579 pub fn placed_container_public_label(
6581 &self,
6582 c: &flatland_protocol::PlacedContainerView,
6583 ) -> String {
6584 let is_owner = match (self.character_id, c.owner_character_id) {
6585 (Some(me), Some(owner)) => me == owner,
6586 _ => false,
6587 };
6588 if is_owner {
6589 c.display_name.clone()
6590 } else {
6591 self.template_display_name(&c.template_id)
6592 }
6593 }
6594
6595 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
6597 let mut out = Vec::new();
6598 for stack in &self.inventory_stacks {
6599 if stack.template_id == KEY_TEMPLATE {
6600 out.push(KeychainEntry {
6601 stack: stack.clone(),
6602 stowed: false,
6603 });
6604 }
6605 }
6606 for stack in &self.keychain_stacks {
6607 if stack.template_id == KEY_TEMPLATE {
6608 out.push(KeychainEntry {
6609 stack: stack.clone(),
6610 stowed: true,
6611 });
6612 }
6613 }
6614 out
6615 }
6616
6617 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
6619 if stack.template_id != KEY_TEMPLATE {
6620 return None;
6621 }
6622 if let Some(name) = stack
6623 .props
6624 .get(PROP_OPENS_CONTAINER_NAME)
6625 .filter(|n| !n.is_empty())
6626 {
6627 return Some(name.clone());
6628 }
6629 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
6630 self.container_name_for_lock_id(opens)
6631 }
6632
6633 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
6635 if stack.template_id == KEY_TEMPLATE {
6636 self.template_display_name(KEY_TEMPLATE)
6637 } else {
6638 stack
6639 .display_name
6640 .clone()
6641 .unwrap_or_else(|| stack.template_id.clone())
6642 }
6643 }
6644
6645 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
6647 if stack.template_id != KEY_TEMPLATE {
6648 return String::new();
6649 }
6650 match self.key_pair_chest_label(stack) {
6651 Some(chest) if self.key_drop_blocked(stack) => {
6652 format!(" [key for {chest} — can't drop while locked]")
6653 }
6654 Some(chest) => format!(" [key for {chest}]"),
6655 None => " [key — unpaired]".into(),
6656 }
6657 }
6658
6659 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
6661 for c in &self.placed_containers {
6662 if c.lock_id.as_deref() == Some(lock) {
6663 return Some(c.display_name.clone());
6664 }
6665 }
6666 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
6667 self.worn
6668 .values()
6669 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
6670 })
6671 }
6672
6673 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
6675 if stack.template_id != KEY_TEMPLATE {
6676 return false;
6677 }
6678 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
6679 return false;
6680 };
6681 for c in &self.placed_containers {
6682 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
6683 return true;
6684 }
6685 }
6686 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
6687 return true;
6688 }
6689 self.worn
6690 .values()
6691 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
6692 }
6693
6694 pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
6696 stack.template_id == PROPERTY_DEED_TEMPLATE
6697 }
6698
6699 pub fn is_property_deed_template(template_id: &str) -> bool {
6700 template_id == PROPERTY_DEED_TEMPLATE
6701 }
6702
6703 pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
6704 stack
6705 .props
6706 .get("plot_id")
6707 .and_then(|s| uuid::Uuid::parse_str(s).ok())
6708 }
6709
6710 pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
6712 let (px, py) = self.player_position();
6713 let (cx, cy) = self.farm_plot_cell_under_player()?;
6714 let tx = cx as f32 + 0.5;
6715 let ty = cy as f32 + 0.5;
6716 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6717 return None;
6718 }
6719 let kind = self.terrain_at(tx, ty).or_else(|| self.terrain_at(px, py));
6720 if kind == Some(TerrainKindView::Tilled) {
6721 return None;
6722 }
6723 if matches!(
6724 kind,
6725 Some(TerrainKindView::ShallowWater)
6726 | Some(TerrainKindView::DeepWater)
6727 | Some(TerrainKindView::Rock)
6728 ) {
6729 return None;
6730 }
6731 Some((tx, ty))
6732 }
6733
6734 fn container_name_in_stacks(
6735 stacks: &[flatland_protocol::ItemStack],
6736 lock: &str,
6737 ) -> Option<String> {
6738 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
6739 for s in stacks {
6740 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
6741 return Some(GameState::stack_container_label(s));
6742 }
6743 if let Some(name) = walk(&s.contents, lock) {
6744 return Some(name);
6745 }
6746 }
6747 None
6748 }
6749 walk(stacks, lock)
6750 }
6751
6752 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
6753 stack
6754 .props
6755 .get(PROP_CUSTOM_NAME)
6756 .cloned()
6757 .or_else(|| stack.display_name.clone())
6758 .unwrap_or_else(|| stack.template_id.clone())
6759 }
6760
6761 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6762 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6763 for s in stacks {
6764 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
6765 return true;
6766 }
6767 if walk(&s.contents, lock) {
6768 return true;
6769 }
6770 }
6771 false
6772 }
6773 walk(stacks, lock)
6774 }
6775
6776 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
6777 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
6778 return Some(stack.clone());
6779 }
6780 for worn in self.worn.values() {
6781 if worn.item_instance_id == Some(instance_id) {
6782 return Some(worn.clone());
6783 }
6784 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
6785 return Some(stack.clone());
6786 }
6787 }
6788 None
6789 }
6790
6791 pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
6793 self.property_zones
6794 .iter()
6795 .enumerate()
6796 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6797 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6798 .map(|(_, z)| z)
6799 }
6800
6801 pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
6803 self.tax_zones
6804 .iter()
6805 .enumerate()
6806 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6807 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6808 .map(|(_, z)| z)
6809 }
6810
6811 pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
6813 let mut max_bps = 0u32;
6814 let mut y = y0 + 0.5;
6815 while y < y1 {
6816 let mut x = x0 + 0.5;
6817 while x < x1 {
6818 if let Some(tz) = self.tax_zone_at(x, y) {
6819 max_bps = max_bps.max(tz.rate_bps);
6820 }
6821 x += 1.0;
6822 }
6823 y += 1.0;
6824 }
6825 max_bps
6826 }
6827
6828 pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6830 let mode = self.claim_mode.as_ref()?;
6831 let w = mode.width_m.max(1) as f32;
6832 let h = mode.height_m.max(1) as f32;
6833 Some((
6834 mode.anchor_x,
6835 mode.anchor_y,
6836 mode.anchor_x + w,
6837 mode.anchor_y + h,
6838 ))
6839 }
6840
6841 pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6843 let mode = self.relocate_mode.as_ref()?;
6844 let x0 = mode.cursor_x.floor();
6845 let y0 = mode.cursor_y.floor();
6846 Some((x0, y0, x0 + 1.0, y0 + 1.0))
6847 }
6848
6849 pub fn claim_quote(&self) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
6852 let mode = self.claim_mode.as_ref()?;
6853 let zone = self.property_zones.iter().find(|z| z.id == mode.zone_id)?;
6854 let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
6855 let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
6856 let zone_area = zone_view_area_m2(zone).max(1.0);
6857 let area_frac = (area / zone_area).clamp(0.0, 1.0);
6858 let weight = self
6859 .property_plot_settings
6860 .as_ref()
6861 .map(|s| s.tax_premium_weight)
6862 .unwrap_or(0.5)
6863 .max(0.0);
6864 let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
6865 let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
6866 let purchase = ((zone.crown_price_copper as f64) * (area_frac as f64) * (premium as f64))
6867 .ceil()
6868 .max(0.0) as u64;
6869 let upkeep = if zone.upkeep_copper_per_day == 0 {
6870 0
6871 } else {
6872 ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
6873 .ceil()
6874 .max(1.0) as u64
6875 };
6876 let copper = crate::currency::copper_from_counts(&self.inventory);
6877 let can_afford = copper >= purchase;
6878 let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
6879 Some((purchase, upkeep, area, premium, can_afford, valid, reason))
6880 }
6881
6882 fn validate_claim_footprint(
6883 &self,
6884 zone: &flatland_protocol::PropertyZoneView,
6885 x0: f32,
6886 y0: f32,
6887 x1: f32,
6888 y1: f32,
6889 area: f32,
6890 ) -> (bool, String) {
6891 let min_area = self
6892 .property_plot_settings
6893 .as_ref()
6894 .map(|s| s.min_plot_area_m2)
6895 .unwrap_or(4.0);
6896 if area + f32::EPSILON < min_area {
6897 return (false, "plot too small".into());
6898 }
6899 if zone.max_area_m2.is_some_and(|m| area > m) {
6900 return (false, "plot exceeds max area".into());
6901 }
6902 if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
6903 return (false, "plot must lie inside the property zone".into());
6904 }
6905 if self
6906 .property_plots
6907 .iter()
6908 .any(|p| rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1))
6909 {
6910 return (false, "plot overlaps an existing claim".into());
6911 }
6912 (true, String::new())
6913 }
6914
6915 pub fn free_property_zone_under_player(&self) -> Option<&flatland_protocol::PropertyZoneView> {
6917 let (px, py) = self.player_position();
6918 let zone = self.property_zone_at(px, py)?;
6919 if self.property_plots.iter().any(|p| point_in_plot(px, py, p)) {
6920 return None;
6921 }
6922 Some(zone)
6923 }
6924
6925 pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
6927 let (px, py) = self.player_position();
6928 self.property_plots
6929 .iter()
6930 .find(|p| p.is_mine && point_in_plot(px, py, p))
6931 }
6932
6933 pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
6935 let (px, py) = self.player_position();
6936 self.property_plots
6937 .iter()
6938 .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
6939 }
6940
6941 pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
6943 if self.farmable_plot_under_player().is_none() {
6944 return None;
6945 }
6946 let (px, py) = self.player_position();
6947 Some((px.floor() as i32, py.floor() as i32))
6948 }
6949
6950 fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
6951 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6952 self.resource_nodes.iter().any(|n| {
6953 let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
6954 ncx == cx && ncy == cy || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
6955 })
6956 }
6957
6958 fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
6959 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6960 let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
6961 || self
6962 .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
6963 .is_some_and(|z| z.kind == TerrainKindView::Tilled);
6964 if !tilled {
6965 return false;
6966 }
6967 !self.resource_node_occupies_farm_cell(cx, cy)
6968 }
6969
6970 pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
6972 let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
6973 return false;
6974 };
6975 self.free_tilled_plant_slot_at(cx, cy)
6976 }
6977
6978 pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
6980 let (px, py) = self.player_position();
6981 for dy in -2..=2 {
6982 for dx in -2..=2 {
6983 let cx = px.floor() as i32 + dx;
6984 let cy = py.floor() as i32 + dy;
6985 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6986 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6987 continue;
6988 }
6989 if self.free_tilled_plant_slot_at(cx, cy) {
6990 return true;
6991 }
6992 }
6993 }
6994 false
6995 }
6996
6997 fn stack_is_farm_seed(&self, stack: &flatland_protocol::ItemStack) -> bool {
6998 if stack.quantity == 0 {
6999 return false;
7000 }
7001 if stack.props.contains_key("seed_for") {
7002 return true;
7003 }
7004 if let Some(entry) = self.item_catalog.get(&stack.template_id) {
7005 if entry.is_farm_seed() {
7006 return true;
7007 }
7008 }
7009 stack.template_id.ends_with("_seed")
7010 }
7011
7012 pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
7014 let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
7015 fn walk(
7016 stacks: &[flatland_protocol::ItemStack],
7017 state: &GameState,
7018 counts: &mut std::collections::HashMap<String, u32>,
7019 ) {
7020 for s in stacks {
7021 if state.stack_is_farm_seed(s) {
7022 *counts.entry(s.template_id.clone()).or_default() += s.quantity;
7023 }
7024 walk(&s.contents, state, counts);
7025 }
7026 }
7027 walk(&self.inventory_stacks, self, &mut counts);
7028 for worn in self.worn.values() {
7029 walk(std::slice::from_ref(worn), self, &mut counts);
7030 }
7031 let mut out: Vec<_> = counts
7032 .into_iter()
7033 .map(|(template_id, quantity)| {
7034 let label = self.template_display_name(&template_id);
7035 (template_id, quantity, label)
7036 })
7037 .collect();
7038 out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
7039 out
7040 }
7041
7042 pub fn first_farm_seed_template(&self) -> Option<String> {
7044 self.farm_seed_entries()
7045 .into_iter()
7046 .next()
7047 .map(|(id, _, _)| id)
7048 }
7049
7050 pub fn clamp_plant_menu(&mut self) {
7051 let n = self.farm_seed_entries().len();
7052 if n == 0 {
7053 self.plant_menu_index = 0;
7054 self.plant_quantity = 1;
7055 return;
7056 }
7057 self.plant_menu_index = self.plant_menu_index.min(n - 1);
7058 let max_qty = self
7059 .farm_seed_entries()
7060 .get(self.plant_menu_index)
7061 .map(|(_, q, _)| *q)
7062 .unwrap_or(1)
7063 .max(1);
7064 self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
7065 }
7066
7067 pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
7068 let entries = self.farm_seed_entries();
7069 let (id, max, label) = entries.get(self.plant_menu_index)?;
7070 let qty = self.plant_quantity.min(*max).max(1);
7071 Some((id.clone(), qty, label.clone()))
7072 }
7073
7074 pub fn location_context_lines(&self) -> Vec<ContextLine> {
7076 let (px, py) = self.player_position();
7077 let inside = self.effective_inside_building();
7078 let mut lines = Vec::new();
7079
7080 if let Some(kind) = self.terrain_at(px, py) {
7081 lines.push(ContextLine {
7082 on_top: true,
7083 text: format!("Terrain: {}", terrain_kind_label(kind)),
7084 });
7085 }
7086
7087 if let Some(id) = inside.as_ref() {
7088 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
7089 lines.push(ContextLine {
7090 on_top: true,
7091 text: format!("Inside: {}", b.label),
7092 });
7093 }
7094 }
7095
7096 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
7097
7098 for node in &self.resource_nodes {
7099 if node.id.starts_with("preview:") {
7100 continue;
7101 }
7102 let dist = distance(px, py, node.x, node.y);
7103 if dist > NEARBY_SCAN_M {
7104 continue;
7105 }
7106 let on_top = dist <= ON_TOP_RADIUS_M;
7107 let prefix = if on_top { "On" } else { "Near" };
7108 let name = resource_node_near_display_label(&node.label);
7109 let action = resource_node_near_action_suffix(node);
7110 nearby.push((
7111 dist,
7112 ContextLine {
7113 on_top,
7114 text: format!("{prefix}: {name} ({dist:.1}m){action}"),
7115 },
7116 ));
7117 }
7118
7119 for drop in &self.ground_drops {
7120 let dist = distance(px, py, drop.x, drop.y);
7121 if dist > INTERACTION_RADIUS_M {
7122 continue;
7123 }
7124 let on_top = dist <= ON_TOP_RADIUS_M;
7125 let name = self.template_display_name(&drop.template_id);
7126 let prefix = if on_top { "On" } else { "Near" };
7127 let qty = if drop.quantity > 1 {
7128 format!(" ×{}", drop.quantity)
7129 } else {
7130 String::new()
7131 };
7132 nearby.push((
7133 dist,
7134 ContextLine {
7135 on_top,
7136 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
7137 },
7138 ));
7139 }
7140
7141 for c in &self.placed_containers {
7142 if !self.placed_container_in_current_space(c) {
7143 continue;
7144 }
7145 let dist = distance(px, py, c.x, c.y);
7146 if dist > CONTAINER_RANGE_M {
7147 continue;
7148 }
7149 let on_top = dist <= ON_TOP_RADIUS_M;
7150 let name = self.placed_container_public_label(c);
7151 let lock = if c.locked { " [locked]" } else { "" };
7152 let prefix = if on_top { "On" } else { "Near" };
7153 nearby.push((
7154 dist,
7155 ContextLine {
7156 on_top,
7157 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
7158 },
7159 ));
7160 }
7161
7162 for npc in &self.npcs {
7163 let dist = distance(px, py, npc.x, npc.y);
7164 if dist > NEARBY_SCAN_M {
7165 continue;
7166 }
7167 let on_top = dist <= ON_TOP_RADIUS_M;
7168 let prefix = if on_top { "On" } else { "Near" };
7169 nearby.push((
7170 dist,
7171 ContextLine {
7172 on_top,
7173 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
7174 },
7175 ));
7176 }
7177
7178 for door in &self.doors {
7179 let dist = distance(px, py, door.x, door.y);
7180 if dist > DOOR_INTERACTION_RADIUS_M {
7181 continue;
7182 }
7183 let building = self
7184 .buildings
7185 .iter()
7186 .find(|b| b.id == door.building_id)
7187 .map(|b| b.label.as_str())
7188 .unwrap_or(door.building_id.as_str());
7189 let player_house = self
7190 .buildings
7191 .iter()
7192 .find(|b| b.id == door.building_id)
7193 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
7194 let action = if inside.is_some() && door.portal.is_some() {
7195 if player_house {
7196 if door.locked {
7197 "locked — l unlock · Enter exit".to_string()
7198 } else if door.open {
7199 "close · Enter exit · l lock".to_string()
7200 } else {
7201 "open · Enter exit · l lock".to_string()
7202 }
7203 } else {
7204 "exit".to_string()
7205 }
7206 } else if player_house {
7207 if door.locked {
7208 "locked — l unlock".to_string()
7209 } else if door.open {
7210 "close · Enter go inside · l lock".to_string()
7211 } else {
7212 "open · l lock".to_string()
7213 }
7214 } else {
7215 "enter".to_string()
7216 };
7217 nearby.push((
7218 dist,
7219 ContextLine {
7220 on_top: dist <= ON_TOP_RADIUS_M,
7221 text: format!("{building} door ({dist:.1}m) — f {action}"),
7222 },
7223 ));
7224 }
7225
7226 if inside.is_none() {
7227 for inter in &self.interactables {
7228 if inter.kind != "quest_board" {
7229 continue;
7230 }
7231 let dist = distance(px, py, inter.x, inter.y);
7232 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
7233 continue;
7234 }
7235 let on_top = dist <= ON_TOP_RADIUS_M;
7236 let prefix = if on_top { "On" } else { "Near" };
7237 let label = if inter.label.is_empty() {
7238 "Quest board".to_string()
7239 } else {
7240 inter.label.clone()
7241 };
7242 nearby.push((
7243 dist,
7244 ContextLine {
7245 on_top,
7246 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
7247 },
7248 ));
7249 }
7250 }
7251
7252 if self.near_liquid_fill_source() {
7253 let on_water = matches!(
7254 self.terrain_at(px, py),
7255 Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
7256 );
7257 let well = self.buildings.iter().find(|b| {
7258 b.tags.iter().any(|t| t == "well") && {
7259 let hw = b.width_m * 0.5;
7260 let hd = b.depth_m * 0.5;
7261 let nx = px.clamp(b.x - hw, b.x + hw);
7262 let ny = py.clamp(b.y - hd, b.y + hd);
7263 let dx = px - nx;
7264 let dy = py - ny;
7265 dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
7266 }
7267 });
7268 if let Some(well) = well {
7269 let name = if well.label.trim().is_empty() {
7270 "Well"
7271 } else {
7272 well.label.as_str()
7273 };
7274 nearby.push((
7275 0.0,
7276 ContextLine {
7277 on_top: true,
7278 text: format!("{name} — Use a vessel from inventory to fill"),
7279 },
7280 ));
7281 } else if on_water {
7282 if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
7283 line.text
7284 .push_str(" — Use a vessel from inventory to fill");
7285 }
7286 } else {
7287 nearby.push((
7288 0.0,
7289 ContextLine {
7290 on_top: true,
7291 text: "Water nearby — Use a vessel from inventory to fill".into(),
7292 },
7293 ));
7294 }
7295 }
7296
7297 if self.claim_mode.is_some() {
7298 nearby.push((
7299 0.0,
7300 ContextLine {
7301 on_top: true,
7302 text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
7303 .into(),
7304 },
7305 ));
7306 } else if let Some(plot) = self.my_plot_under_player() {
7307 let name = plot_public_label(plot);
7308 let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
7309 format!("{name} — f again to sell to crown")
7310 } else {
7311 format!(
7312 "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
7313 )
7314 };
7315 nearby.push((
7316 0.0,
7317 ContextLine {
7318 on_top: true,
7319 text: prompt,
7320 },
7321 ));
7322 } else if let Some(plot) = self.farmable_plot_under_player() {
7323 let name = plot_public_label(plot);
7324 let disc = if plot.farm_public {
7325 plot.public_tax_discount_bps / 100
7326 } else {
7327 plot.farm_allow
7328 .iter()
7329 .find(|g| Some(g.character_id) == self.character_id)
7330 .map(|g| g.tax_discount_bps / 100)
7331 .unwrap_or(0)
7332 };
7333 nearby.push((
7334 0.0,
7335 ContextLine {
7336 on_top: true,
7337 text: format!(
7338 "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
7339 ),
7340 },
7341 ));
7342 } else if let Some(zone) = self.free_property_zone_under_player() {
7343 let label = zone
7344 .label
7345 .as_deref()
7346 .filter(|s| !s.trim().is_empty())
7347 .unwrap_or(zone.id.as_str());
7348 nearby.push((
7349 0.0,
7350 ContextLine {
7351 on_top: true,
7352 text: format!("Claimable land: {label} — k buy plot"),
7353 },
7354 ));
7355 }
7356
7357 for entity in &self.entities {
7358 if entity.id == self.entity_id {
7359 continue;
7360 }
7361 let dist = distance(
7362 px,
7363 py,
7364 entity.transform.position.x,
7365 entity.transform.position.y,
7366 );
7367 if dist > NEARBY_SCAN_M {
7368 continue;
7369 }
7370 let label = if entity.label.is_empty() {
7371 format!("entity {}", entity.id)
7372 } else {
7373 entity.label.clone()
7374 };
7375 nearby.push((
7376 dist,
7377 ContextLine {
7378 on_top: dist <= ON_TOP_RADIUS_M,
7379 text: format!("Near: {label} ({dist:.1}m)"),
7380 },
7381 ));
7382 }
7383
7384 nearby.sort_by(|a, b| {
7385 a.0.partial_cmp(&b.0)
7386 .unwrap_or(std::cmp::Ordering::Equal)
7387 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
7388 });
7389 lines.extend(nearby.into_iter().map(|(_, l)| l));
7390
7391 if lines.is_empty() {
7392 lines.push(ContextLine {
7393 on_top: false,
7394 text: "(nothing notable nearby)".into(),
7395 });
7396 }
7397
7398 lines
7399 }
7400}
7401
7402#[derive(Debug, Clone)]
7404pub struct ContextLine {
7405 pub on_top: bool,
7406 pub text: String,
7407}
7408
7409const ON_TOP_RADIUS_M: f32 = 0.65;
7410const NEARBY_SCAN_M: f32 = 5.0;
7411
7412pub fn resource_node_near_display_label(label: &str) -> String {
7414 label
7415 .strip_suffix(" (growing)")
7416 .unwrap_or(label)
7417 .to_string()
7418}
7419
7420fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
7421 let t = label.trim();
7422 if t.is_empty() || t == id {
7423 return true;
7424 }
7425 let lower = t.to_ascii_lowercase();
7426 if lower.contains("_copy") {
7427 return true;
7428 }
7429 false
7430}
7431
7432fn humanize_item_template_label(template: &str) -> String {
7433 let base = template.rsplit('/').next().unwrap_or(template).trim();
7434 if base.is_empty() {
7435 return "Resource".into();
7436 }
7437 let stripped = base
7438 .strip_prefix("crop-")
7439 .or_else(|| base.strip_prefix("crop_"))
7440 .unwrap_or(base);
7441 stripped
7442 .split(|c: char| c == '-' || c == '_')
7443 .filter(|p| !p.is_empty())
7444 .map(|p| {
7445 let mut chars = p.chars();
7446 match chars.next() {
7447 Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
7448 None => String::new(),
7449 }
7450 })
7451 .collect::<Vec<_>>()
7452 .join(" ")
7453}
7454
7455pub fn resource_node_id_suffix(id: &str) -> String {
7457 let chars: Vec<char> = id
7458 .chars()
7459 .rev()
7460 .filter(|c| c.is_ascii_alphanumeric())
7461 .take(4)
7462 .collect();
7463 chars.into_iter().rev().collect()
7464}
7465
7466pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
7468 resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
7469}
7470
7471pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
7472 let cleaned = resource_node_near_display_label(label);
7473 let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
7474 cleaned
7475 } else if !item_template.trim().is_empty() {
7476 humanize_item_template_label(item_template)
7477 } else {
7478 id.to_string()
7479 };
7480 let suffix = resource_node_id_suffix(id);
7481 if suffix.is_empty() {
7482 friendly
7483 } else {
7484 format!("{friendly} ({suffix})")
7485 }
7486}
7487
7488pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
7490 use flatland_protocol::ResourceNodeState;
7491 if node.harvest_off {
7492 return " (decorative)".to_string();
7493 }
7494 if let Some(p) = node.growth_progress {
7495 if p < 1.0 - f32::EPSILON {
7496 let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
7497 return format!(" (growing, {pct}%)");
7498 }
7499 return " — f harvest".to_string();
7500 }
7501 match node.state {
7502 ResourceNodeState::Available => " — f harvest".to_string(),
7503 ResourceNodeState::Harvesting => " (being harvested)".to_string(),
7504 ResourceNodeState::Cooldown => " (depleted)".to_string(),
7505 }
7506}
7507
7508fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
7509 use flatland_protocol::TerrainKindView;
7510 match kind {
7511 TerrainKindView::Grass => "Grass",
7512 TerrainKindView::Dirt => "Dirt",
7513 TerrainKindView::Tilled => "Tilled",
7514 TerrainKindView::Desert => "Desert",
7515 TerrainKindView::Hill => "Hills",
7516 TerrainKindView::Bog => "Bog",
7517 TerrainKindView::Beach => "Beach",
7518 TerrainKindView::ShallowWater => "Shallow water",
7519 TerrainKindView::DeepWater => "Deep water",
7520 TerrainKindView::Trail => "Trail",
7521 TerrainKindView::Road => "Road",
7522 TerrainKindView::Rock => "Rock",
7523 }
7524}
7525
7526fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
7527 crate::world_zones::zone_rects_contain(rects, x, y)
7528}
7529
7530fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
7531 zone.rects
7532 .iter()
7533 .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
7534 .sum()
7535}
7536
7537fn claim_rect_fully_inside_zone(
7538 zone: &flatland_protocol::PropertyZoneView,
7539 x0: f32,
7540 y0: f32,
7541 x1: f32,
7542 y1: f32,
7543) -> bool {
7544 let mut y = y0 + 0.5;
7545 while y < y1 {
7546 let mut x = x0 + 0.5;
7547 while x < x1 {
7548 if !zone_rects_contain(&zone.rects, x, y) {
7549 return false;
7550 }
7551 x += 1.0;
7552 }
7553 y += 1.0;
7554 }
7555 true
7556}
7557
7558fn rects_overlap_half_open(
7559 ax0: f32,
7560 ay0: f32,
7561 ax1: f32,
7562 ay1: f32,
7563 bx0: f32,
7564 by0: f32,
7565 bx1: f32,
7566 by1: f32,
7567) -> bool {
7568 ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
7569}
7570
7571fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
7572 x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
7573}
7574
7575fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
7576 plot_public_label(p)
7577}
7578
7579fn plot_size_fallback_label(p: &flatland_protocol::PropertyPlotView) -> String {
7580 let w = (p.x1 - p.x0).abs();
7581 let d = (p.y1 - p.y0).abs();
7582 format!("Plot ({w:.0}×{d:.0} m)")
7583}
7584
7585pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
7587 let zone = p
7588 .zone_label
7589 .as_deref()
7590 .filter(|s| !s.trim().is_empty())
7591 .unwrap_or_else(|| {
7592 if p.property_zone_id.is_empty() {
7593 "Homestead"
7594 } else {
7595 p.property_zone_id.as_str()
7596 }
7597 });
7598 let label = if !p.label.trim().is_empty() {
7599 p.label.clone()
7600 } else if !p.plot_code.trim().is_empty() {
7601 p.plot_code.clone()
7602 } else {
7603 plot_size_fallback_label(p)
7604 };
7605 match p
7606 .owner_label
7607 .as_deref()
7608 .map(str::trim)
7609 .filter(|s| !s.is_empty())
7610 {
7611 Some(owner) => format!("{owner} — {zone} — {label}"),
7612 None => format!("{zone} — {label}"),
7613 }
7614}
7615
7616pub fn plot_stop_label(
7621 plots: &[flatland_protocol::PropertyPlotView],
7622 plot_id: uuid::Uuid,
7623) -> String {
7624 plots
7625 .iter()
7626 .find(|p| p.plot_id == plot_id)
7627 .map(plot_public_label)
7628 .unwrap_or_else(|| {
7629 let s = plot_id.to_string();
7630 format!("plot {}", s.get(..8).unwrap_or(s.as_str()))
7631 })
7632}
7633
7634fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
7636 let a = x0.min(x1).floor();
7637 let b = y0.min(y1).floor();
7638 let mut c = x0.max(x1).ceil();
7639 let mut d = y0.max(y1).ceil();
7640 if (c - a) < 1.0 {
7641 c = a + 1.0;
7642 }
7643 if (d - b) < 1.0 {
7644 d = b + 1.0;
7645 }
7646 (a, b, c, d)
7647}
7648
7649fn humanize_template_id(template_id: &str) -> String {
7650 if looks_like_template_uuid(template_id) {
7652 return "Unknown item".into();
7653 }
7654 template_id
7655 .split('_')
7656 .map(|word| {
7657 let mut chars = word.chars();
7658 match chars.next() {
7659 None => String::new(),
7660 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
7661 }
7662 })
7663 .collect::<Vec<_>>()
7664 .join(" ")
7665}
7666
7667fn looks_like_template_uuid(template_id: &str) -> bool {
7668 let bytes = template_id.as_bytes();
7669 if bytes.len() != 36 {
7670 return false;
7671 }
7672 let is_hex = |b: u8| b.is_ascii_hexdigit();
7673 let groups = [8usize, 4, 4, 4, 12];
7674 let mut i = 0;
7675 for (gi, &len) in groups.iter().enumerate() {
7676 if gi > 0 {
7677 if bytes.get(i) != Some(&b'-') {
7678 return false;
7679 }
7680 i += 1;
7681 }
7682 for _ in 0..len {
7683 if !bytes.get(i).copied().is_some_and(is_hex) {
7684 return false;
7685 }
7686 i += 1;
7687 }
7688 }
7689 true
7690}
7691
7692const HARVEST_RANGE_M: f32 = 1.5;
7694
7695pub struct GameClient<S: PlayConnection> {
7696 session: S,
7697 seq: Seq,
7698 pub state: GameState,
7699 last_move_forward: f32,
7700 last_move_strafe: f32,
7701}
7702
7703impl<S: PlayConnection> GameClient<S> {
7704 pub fn new(session: S) -> Self {
7705 let session_id = session.session_id();
7706 let entity_id = session.entity_id();
7707 let mut client = Self {
7708 session,
7709 seq: 0,
7710 last_move_forward: 0.0,
7711 last_move_strafe: 0.0,
7712 state: GameState {
7713 session_id,
7714 entity_id,
7715 character_id: None,
7716 tick: 0,
7717 chunk_rev: 0,
7718 content_rev: 0,
7719 publish_rev: 0,
7720 entities: Vec::new(),
7721 player: None,
7722 resource_nodes: Vec::new(),
7723 ground_drops: Vec::new(),
7724 placed_containers: Vec::new(),
7725 buildings: Vec::new(),
7726 doors: Vec::new(),
7727 interior_map: None,
7728 npcs: Vec::new(),
7729 blueprints: Vec::new(),
7730 building_materials: Vec::new(),
7731 world_x0: 0.0,
7732 world_y0: 0.0,
7733 world_width_m: 0.0,
7734 world_height_m: 0.0,
7735 terrain_zones: Vec::new(),
7736 z_platforms: Vec::new(),
7737 z_transitions: Vec::new(),
7738 z_bands_outdoor_backup: None,
7739 world_clock: flatland_protocol::WorldClock::default(),
7740 inventory: std::collections::HashMap::new(),
7741 inventory_hints: std::collections::HashMap::new(),
7742 item_catalog: std::collections::HashMap::new(),
7743 logs: VecDeque::new(),
7744 intents_sent: 0,
7745 ticks_received: 0,
7746 connected: false,
7747 disconnect_reason: None,
7748 show_stats: false,
7749 hud_log_hidden: false,
7750 show_equip_menu: false,
7751 equip_menu_index: 0,
7752 show_craft_menu: false,
7753 show_plot_build_menu: false,
7754 plot_build_focus_wall: true,
7755 plot_build_wall_index: 0,
7756 plot_build_roof_index: 0,
7757 craft_menu_index: 0,
7758 craft_batch_quantity: 1,
7759 craft_tab: CraftTab::Ready,
7760 craft_filter: String::new(),
7761 craft_filter_focused: false,
7762 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
7763 show_shop_menu: false,
7764 shop_catalog: None,
7765 bank_panel: None,
7766 bank_menu_index: 0,
7767 bank_ui_mode: BankUiMode::Menu,
7768 storage_panel: None,
7769 market_panel: None,
7770 market_menu_index: 0,
7771 market_filter: String::new(),
7772 market_filter_focused: false,
7773 market_category_filter: None,
7774 market_buy_confirm: None,
7775 market_ui_mode: MarketUiMode::Browse,
7776 storage_menu_index: 0,
7777 storage_ui_mode: StorageUiMode::Menu,
7778 shop_tab: ShopTab::default(),
7779 shop_menu_index: 0,
7780 shop_quantity: 1,
7781 shop_trade_log: VecDeque::new(),
7782 show_npc_verb_menu: false,
7783 npc_verb_target: None,
7784 npc_verb_index: 0,
7785 npc_verb_notice: None,
7786 player_verbs: crate::social::PlayerVerbState::default(),
7787 social_chat: crate::social::SocialChatState::default(),
7788 trade_ui: crate::social::TradeUiState::default(),
7789 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
7790 show_npc_chat: false,
7791 npc_chat: None,
7792 show_inventory_menu: false,
7793 inventory_menu_index: 0,
7794 inventory_tab: InventoryTab::OnPerson,
7795 inventory_filter: String::new(),
7796 inventory_filter_focused: false,
7797 show_move_picker: false,
7798 show_rename_prompt: false,
7799 rename_plot_id: None,
7800 highlighted_plot_id: None,
7801 show_worker_rename: false,
7802 rename_buffer: String::new(),
7803 move_picker_index: 0,
7804 move_picker: None,
7805 show_grant_picker: false,
7806 grant_picker_index: 0,
7807 grant_picker: None,
7808 show_destroy_picker: false,
7809 destroy_confirm_pending: false,
7810 destroy_picker: None,
7811 combat_target: None,
7812 combat_target_label: None,
7813 ground_target: None,
7814 combat_fx: Vec::new(),
7815 ground_hazards: Vec::new(),
7816 property_zones: Vec::new(),
7817 tax_zones: Vec::new(),
7818 growth_zones: Vec::new(),
7819 biome_zones: Vec::new(),
7820 terrain_kind_nav: Vec::new(),
7821 property_plots: Vec::new(),
7822 property_plot_settings: None,
7823 claim_mode: None,
7824 relocate_mode: None,
7825 sell_plot_confirm: None,
7826 sell_plot_armed_at: None,
7827 show_plant_menu: false,
7828 plant_menu_index: 0,
7829 show_farm_access: false,
7830 farm_access_name_draft: String::new(),
7831 farm_access_discount_bps: 0,
7832 farm_access_index: 0,
7833 plant_quantity: 1,
7834 in_combat: false,
7835 auto_attack: true,
7836 combat_has_los: false,
7837 attack_cd_ticks: 0,
7838 gcd_ticks: 0,
7839 weapon_ability_id: "unarmed".into(),
7840 mainhand_template_id: None,
7841 mainhand_label: None,
7842 mainhand_instance_id: None,
7843 offhand_template_id: None,
7844 offhand_label: None,
7845 offhand_instance_id: None,
7846 mainhand_hand_slots: 1,
7847 defense: None,
7848 worn: BTreeMap::new(),
7849 carry_mass: 0.0,
7850 carry_mass_max: 0.0,
7851 encumbrance: flatland_protocol::EncumbranceState::Light,
7852 move_speed_mps: 0.0,
7853 move_speed_mult: 0.0,
7854 inventory_stacks: Vec::new(),
7855 keychain_stacks: Vec::new(),
7856 whisper_pouch_stacks: Vec::new(),
7857 combat_target_detail: None,
7858 statuses: Vec::new(),
7859 cast_progress: None,
7860 timed_channel: None,
7861 plot_build_offer: None,
7862 ability_cooldowns: Vec::new(),
7863 blocking_active: false,
7864 max_target_slots: 1,
7865 combat_slots: Vec::new(),
7866 rotation_presets: Vec::new(),
7867 known_abilities: Vec::new(),
7868 ability_meta: std::collections::HashMap::new(),
7869 ability_mastery: std::collections::HashMap::new(),
7870 hotbar: vec![None; 9],
7871 max_abilities_per_rotation: 0,
7872 show_loadout_menu: false,
7873 show_keychain_menu: false,
7874 keychain_menu_index: 0,
7875 show_rotation_editor: false,
7876 loadout_menu_index: 0,
7877 loadout_hotbar_slot: 1,
7878 loadout_ability_index: 0,
7879 loadout_focus_presets: false,
7880 rotation_editor: RotationEditorState::default(),
7881 harvest_in_progress: false,
7882 harvest_started_at: None,
7883 pending_craft_ack: None,
7884 craft_channel_blueprint_id: None,
7885 pending_worker_job_ack: None,
7886 attending_worker_instance_id: None,
7887 quest_log: Vec::new(),
7888 interactables: Vec::new(),
7889 ledger: None,
7890 career: None,
7891 character_sheet_tab: CharacterSheetTab::Character,
7892 ledger_period: LedgerPeriod::Day,
7893 show_quest_offer: false,
7894 pending_quest_offers: Vec::new(),
7895 quest_offer_index: 0,
7896 show_quest_menu: false,
7897 quest_menu_index: 0,
7898 quest_withdraw_confirm: false,
7899 hired_workers: Vec::new(),
7900 show_workers_menu: false,
7901 workers_menu_index: 0,
7902 worker_dismiss_confirmation: None,
7903 workers_menu_compact: false,
7904 worker_step_display: BTreeMap::new(),
7905 worker_error_display: BTreeMap::new(),
7906 worker_health_ring_until: BTreeMap::new(),
7907 pending_worker_hire_since: None,
7908 show_worker_give_picker: false,
7909 worker_give_picker_index: 0,
7910 worker_give_picker: None,
7911 show_worker_give_target_picker: false,
7912 worker_give_target_picker_index: 0,
7913 worker_give_target_picker: None,
7914 show_worker_take_picker: false,
7915 worker_take_picker_index: 0,
7916 worker_take_picker: None,
7917 show_worker_teach_picker: false,
7918 worker_teach_picker_index: 0,
7919 worker_teach_picker: None,
7920 worker_route_editor: None,
7921 progression_curve: None,
7922 },
7923 };
7924 client.state.apply_client_ui_prefs();
7925 client
7926 }
7927
7928 pub fn entity_id(&self) -> EntityId {
7929 self.state.entity_id
7930 }
7931
7932 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
7933 if self.state.connected {
7934 return Ok(());
7935 }
7936
7937 loop {
7938 match self.session.next_event().await {
7939 Some(SessionEvent::Welcome {
7940 session_id,
7941 entity_id,
7942 snapshot,
7943 }) => {
7944 self.state
7945 .restore_from_welcome(session_id, entity_id, &snapshot);
7946 self.state.apply_client_ui_prefs();
7947 self.state.push_log(format!(
7948 "Connected — session {session_id}, entity {entity_id}"
7949 ));
7950 return Ok(());
7951 }
7952 Some(SessionEvent::Disconnected { .. }) => {
7953 anyhow::bail!("disconnected before welcome");
7954 }
7955 Some(_) => continue,
7956 None => anyhow::bail!("session closed before welcome"),
7957 }
7958 }
7959 }
7960
7961 pub fn drain_events(&mut self) {
7963 while let Some(event) = self.session.try_next_event() {
7964 if self.handle_event_sync(event).is_err() {
7965 break;
7966 }
7967 }
7968 }
7969
7970 pub async fn next_event(&mut self) -> Option<SessionEvent> {
7972 self.session.next_event().await
7973 }
7974
7975 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
7976 self.handle_event_sync(event)
7977 }
7978
7979 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
7980 match event {
7981 SessionEvent::Welcome {
7982 session_id,
7983 entity_id,
7984 snapshot,
7985 } => {
7986 let resumed = self.state.connected;
7987 self.state
7988 .restore_from_welcome(session_id, entity_id, &snapshot);
7989 if resumed {
7990 self.state.push_log(format!(
7991 "Session restored — session {session_id}, entity {entity_id}"
7992 ));
7993 }
7994 }
7995 SessionEvent::ContentUpdated { snapshot } => {
7996 self.state
7997 .apply_snapshot_fields(&snapshot, self.state.entity_id);
7998 self.state.push_log(format!(
7999 "World updated (content rev {})",
8000 snapshot.content_rev
8001 ));
8002 }
8003 SessionEvent::QuestCatalogUpdated(update) => {
8004 self.state.push_log(format!(
8005 "Quest board updated (revision {}, {} new, {} retired)",
8006 update.revision,
8007 update.accepted.len(),
8008 update.retired.len()
8009 ));
8010 }
8011 SessionEvent::Tick(delta) => {
8012 self.state.apply_tick_fields(&delta, self.state.entity_id);
8013 self.state.ticks_received += 1;
8014 }
8015 SessionEvent::IntentAck {
8016 entity_id,
8017 seq,
8018 tick,
8019 } => {
8020 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
8021 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
8022 if *craft_seq == seq {
8023 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
8024 if batches > 1 {
8025 self.state.push_log(format!("Crafting {label} ×{batches}…"));
8026 } else {
8027 self.state.push_log(format!("Crafting {label}…"));
8028 }
8029 }
8030 }
8031 if self
8032 .state
8033 .pending_worker_job_ack
8034 .as_ref()
8035 .is_some_and(|p| p.seq == seq)
8036 {
8037 let pending = self.state.pending_worker_job_ack.take().unwrap();
8038 if pending.idle {
8039 self.state.push_log(format!(
8040 "Route cleared for {} — worker idle",
8041 pending.worker_label
8042 ));
8043 } else {
8044 self.state.push_log(format!(
8045 "Route saved for {} — {} stop(s), job loop active",
8046 pending.worker_label, pending.stop_count
8047 ));
8048 }
8049 if self
8050 .state
8051 .worker_route_editor
8052 .as_ref()
8053 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
8054 {
8055 self.close_worker_route_editor();
8056 }
8057 }
8058 }
8059 SessionEvent::Chat(msg) => {
8060 let label = match msg.channel {
8061 flatland_protocol::ChatChannel::Nearby => "nearby",
8062 flatland_protocol::ChatChannel::Direct => "speak",
8063 flatland_protocol::ChatChannel::Whisper => "whisper",
8064 flatland_protocol::ChatChannel::WhisperStone => "stone",
8065 };
8066 let clarity = match msg.clarity {
8067 flatland_protocol::ChatClarity::Clear => "",
8068 flatland_protocol::ChatClarity::Partial => "~",
8069 flatland_protocol::ChatClarity::Heavy => "…",
8070 };
8071 self.state.push_log(format!(
8072 "[{label}{clarity}] {}: {}",
8073 msg.from_name, msg.text
8074 ));
8075 let now_ms = std::time::SystemTime::now()
8076 .duration_since(std::time::UNIX_EPOCH)
8077 .map(|d| d.as_millis() as u64)
8078 .unwrap_or(0);
8079 self.state
8080 .social_chat
8081 .note_speech(&msg, self.state.entity_id, now_ms);
8082 self.state
8083 .social_chat
8084 .push(crate::social::ChatLogEntry::from_message(
8085 msg,
8086 self.state.entity_id,
8087 ));
8088 }
8089 SessionEvent::TradeOpened(panel) => {
8090 self.state.social_chat.pending_trade = None;
8091 let peer = panel.peer_name.clone();
8092 self.state.trade_ui.open(panel);
8093 self.state.social_chat.push_system(format!(
8094 "Trade open with {peer} — p present · r ready · Esc cancel"
8095 ));
8096 self.state
8097 .social_chat
8098 .push_cue(crate::social::AudioCue::TradeOpened);
8099 }
8100 SessionEvent::TradeClosed { reason } => {
8101 self.state.push_log(reason.clone());
8102 self.state.social_chat.push_system(reason);
8103 self.state.trade_ui.close();
8104 }
8105 SessionEvent::HarvestResult(result) => {
8106 self.state.clear_harvest_state();
8107 crate::harvest_trace!(
8108 entity_id = self.state.entity_id,
8109 node_id = %result.node_id,
8110 template = %result.item_template,
8111 quantity = result.quantity,
8112 client_tick = self.state.tick,
8113 "client applied harvest result"
8114 );
8115 let msg = if result.quantity == 0 {
8116 format!(
8117 "Harvested {} x0 — nothing dropped (loot table rolled empty)",
8118 result.item_template
8119 )
8120 } else {
8121 format!(
8122 "Harvested {} x{} (on the ground — press P to pick up)",
8123 result.item_template, result.quantity
8124 )
8125 };
8126 self.state.push_log(msg);
8127 }
8128 SessionEvent::CraftResult(result) => {
8129 for stack in &result.consumed {
8130 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
8131 *qty = qty.saturating_sub(stack.quantity);
8132 if *qty == 0 {
8133 self.state.inventory.remove(&stack.template_id);
8134 }
8135 }
8136 }
8137 for stack in &result.outputs {
8138 *self
8139 .state
8140 .inventory
8141 .entry(stack.template_id.clone())
8142 .or_insert(0) += stack.quantity;
8143 }
8144 self.state.craft_record_completed(&result.blueprint_id);
8145 if let Some(output) = result.outputs.first() {
8146 if result.batch_total > 1 {
8147 self.state.push_log(format!(
8148 "Crafted {} x{} ({}/{})",
8149 output.template_id,
8150 output.quantity,
8151 result.batch_index,
8152 result.batch_total
8153 ));
8154 } else {
8155 self.state.push_log(format!(
8156 "Crafted {} x{}",
8157 output.template_id, output.quantity
8158 ));
8159 }
8160 } else {
8161 self.state
8162 .push_log(format!("Craft finished: {}", result.blueprint_id));
8163 }
8164 }
8165 SessionEvent::Death(notice) => {
8166 self.state.clear_harvest_state();
8167 self.state.push_log(notice.message.clone());
8168 self.state.push_log(format!(
8169 "Respawned at ({:.1}, {:.1})",
8170 notice.respawn_x, notice.respawn_y
8171 ));
8172 }
8173 SessionEvent::Interaction(notice) => {
8174 if notice.message.starts_with("Harvest failed:") {
8175 self.state.clear_harvest_state();
8176 }
8177 if notice.message.starts_with("Can't do that:") {
8178 self.state.pending_worker_hire_since = None;
8179 self.state.pending_craft_ack = None;
8180 self.state.craft_channel_blueprint_id = None;
8181 if let Some(pending) = self.state.pending_worker_job_ack.take() {
8182 if let Some(w) = self
8183 .state
8184 .hired_workers
8185 .iter_mut()
8186 .find(|w| w.instance_id == pending.worker_instance_id)
8187 {
8188 w.route = pending.prev_route;
8189 w.mode = pending.prev_mode;
8190 w.step_label = pending.prev_step_label;
8191 w.last_error = pending.prev_last_error;
8192 }
8193 let reason = notice
8194 .message
8195 .strip_prefix("Can't do that:")
8196 .unwrap_or(¬ice.message)
8197 .trim();
8198 self.state.push_log(format!(
8199 "Route save failed for {}: {reason}",
8200 pending.worker_label
8201 ));
8202 }
8203 let reason = notice
8204 .message
8205 .strip_prefix("Can't do that:")
8206 .unwrap_or(¬ice.message)
8207 .trim();
8208 if reason.contains("already tilled") {
8209 if let Some(plot) = self.state.my_plot_under_player() {
8210 self.state.sell_plot_confirm = Some(plot.plot_id);
8211 self.state.sell_plot_armed_at = Some(Instant::now());
8212 }
8213 }
8214 }
8215 if notice.message.starts_with("Cast failed:") {
8216 self.state.cast_progress = None;
8217 }
8218 if notice.message.contains("slain the") {
8219 self.state.combat_target = None;
8220 self.state.combat_target_label = None;
8221 }
8222 if notice.message.contains("wants to trade") {
8224 if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
8225 let from_name = notice
8226 .message
8227 .split(" wants to trade")
8228 .next()
8229 .unwrap_or("Player")
8230 .to_string();
8231 self.state.social_chat.pending_trade =
8232 Some(crate::social::PendingTradeRequest {
8233 from_entity,
8234 from_name: from_name.clone(),
8235 });
8236 self.state.social_chat.push_system(format!(
8237 "{from_name} wants to trade — [Y] accept · [N] decline"
8238 ));
8239 self.state
8240 .social_chat
8241 .push_cue(crate::social::AudioCue::TradeOffer);
8242 }
8243 }
8244 if notice.message.starts_with("trade request declined") {
8245 self.state.social_chat.push_system(notice.message.clone());
8246 self.state
8247 .social_chat
8248 .push_cue(crate::social::AudioCue::TradeDeclined);
8249 }
8250 if self.state.show_npc_verb_menu && !notice.message.trim().is_empty() {
8252 self.state.npc_verb_notice = Some(notice.message.clone());
8253 self.state
8254 .social_chat
8255 .push_cue(crate::social::AudioCue::UiError);
8256 }
8257 self.state.apply_interaction_notice(¬ice);
8258 self.state.push_log(notice.message.clone());
8259 }
8260 SessionEvent::ShopOpened(catalog) => {
8261 self.state.apply_shop_catalog(catalog);
8262 }
8263 SessionEvent::BankOpened(panel) => {
8264 self.state.apply_bank_panel(panel);
8265 }
8266 SessionEvent::StorageOpened(panel) => {
8267 self.state.apply_storage_panel(panel);
8268 }
8269 SessionEvent::MarketOpened(panel) => {
8270 self.state.apply_market_panel(panel);
8271 }
8272 SessionEvent::NpcTalkOpened(opened) => {
8273 self.state.show_npc_verb_menu = false;
8274 if self.state.npc_verb_target.is_none() {
8275 self.state.npc_verb_target = Some(opened.npc_id.clone());
8276 }
8277 let label = opened.npc_label.clone();
8278 let banner = if !opened.trade_allowed {
8279 Some("Trade is unavailable right now.".to_string())
8280 } else {
8281 None
8282 };
8283 self.state.show_npc_chat = true;
8284 self.state.npc_chat = Some(NpcChatState {
8285 npc_id: opened.npc_id,
8286 npc_label: opened.npc_label,
8287 lines: if opened.greeting.is_empty() {
8288 vec![]
8289 } else {
8290 vec![format!("{label}: {}", opened.greeting)]
8291 },
8292 input: String::new(),
8293 pending: opened.greeting.is_empty(),
8294 talk_depth: opened.talk_depth,
8295 trade_allowed: opened.trade_allowed,
8296 banner,
8297 suggested_topics: opened.suggested_topics,
8298 });
8299 }
8300 SessionEvent::NpcTalkPending(_) => {
8301 if let Some(chat) = self.state.npc_chat.as_mut() {
8302 chat.pending = true;
8303 }
8304 }
8305 SessionEvent::NpcTalkReply(reply) => {
8306 if let Some(chat) = self.state.npc_chat.as_mut() {
8307 if chat.npc_id == reply.npc_id {
8308 chat.pending = false;
8309 if reply.trade_disabled {
8310 chat.trade_allowed = false;
8311 chat.banner = Some("Trade is unavailable right now.".to_string());
8312 }
8313 if reply.wind_down {
8314 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
8315 if chat.banner.is_none() {
8316 chat.banner =
8317 Some("They're wrapping up — keep it brief.".to_string());
8318 }
8319 }
8320 chat.lines
8321 .push(format!("{}: {}", chat.npc_label, reply.line));
8322 }
8323 }
8324 }
8325 SessionEvent::NpcTalkClosed(closed) => {
8326 if self
8327 .state
8328 .npc_chat
8329 .as_ref()
8330 .is_some_and(|c| c.npc_id == closed.npc_id)
8331 {
8332 self.state.show_npc_chat = false;
8333 self.state.npc_chat = None;
8334 }
8335 }
8336 SessionEvent::NpcTalkError(err) => {
8337 self.state.push_log(format!("Talk failed: {}", err.reason));
8338 if let Some(chat) = self.state.npc_chat.as_mut() {
8339 chat.pending = false;
8340 }
8341 }
8342 SessionEvent::UseResult(result) => {
8343 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
8346 *qty = qty.saturating_sub(1);
8347 if *qty == 0 {
8348 self.state.inventory.remove(&result.template_id);
8349 }
8350 }
8351 }
8352 SessionEvent::QuestOffer(offer) => {
8353 let title = offer.title.clone();
8354 self.state.push_quest_offer(offer);
8355 self.state.push_log(format!("Quest offered: {title}"));
8356 }
8357 SessionEvent::QuestAccepted(notice) => {
8358 self.state.remove_quest_offer(¬ice.quest_id);
8359 self.state.push_log(notice.message);
8360 }
8361 SessionEvent::QuestWithdrawn(notice) => {
8362 self.state.show_quest_menu = false;
8363 self.state.quest_withdraw_confirm = false;
8364 self.state.push_log(notice.message);
8365 }
8366 SessionEvent::QuestStepCompleted(notice) => {
8367 self.state.push_log(notice.message);
8368 }
8369 SessionEvent::QuestCompleted(notice) => {
8370 self.state.push_log(notice.message);
8371 }
8372 SessionEvent::Disconnected { reason } => {
8373 self.state.clear_harvest_state();
8374 self.state.connected = false;
8375 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
8376 if let Some(r) = &self.state.disconnect_reason {
8377 self.state.push_log(format!("Disconnected: {r}"));
8378 } else {
8379 self.state.push_log("Disconnected from server");
8380 }
8381 }
8382 }
8383 Ok(())
8384 }
8385
8386 pub fn is_connected(&self) -> bool {
8387 self.state.connected
8388 }
8389
8390 pub fn close_overlays(&mut self) {
8391 self.state.show_stats = false;
8392 self.state.show_craft_menu = false;
8393 self.state.show_plot_build_menu = false;
8394 self.state.show_shop_menu = false;
8395 self.state.shop_catalog = None;
8396 self.state.show_npc_verb_menu = false;
8397 self.state.npc_verb_target = None;
8398 self.state.show_npc_chat = false;
8399 self.state.npc_chat = None;
8400 self.state.show_inventory_menu = false;
8401 self.state.show_loadout_menu = false;
8402 self.state.show_rotation_editor = false;
8403 self.state.rotation_editor.reset();
8404 self.state.show_rename_prompt = false;
8405 self.state.show_worker_rename = false;
8406 self.state.rename_buffer.clear();
8407 self.state.show_move_picker = false;
8408 self.state.move_picker = None;
8409 self.state.show_destroy_picker = false;
8410 self.state.destroy_confirm_pending = false;
8411 self.state.destroy_picker = None;
8412 self.state.show_quest_offer = false;
8413 self.state.clear_quest_offers();
8414 self.state.show_quest_menu = false;
8415 self.state.quest_withdraw_confirm = false;
8416 self.state.show_workers_menu = false;
8417 self.close_worker_give_picker();
8418 self.close_worker_give_target_picker();
8419 self.close_worker_take_picker();
8420 self.close_worker_teach_picker();
8421 self.state.worker_route_editor = None;
8422 self.state.claim_mode = None;
8423 self.state.relocate_mode = None;
8424 self.state.sell_plot_confirm = None;
8425 self.state.sell_plot_armed_at = None;
8426 self.close_farm_access_panel();
8427 if self.state.show_plant_menu {
8428 self.close_plant_menu();
8429 }
8430 }
8431
8432 pub fn back_on_esc(&mut self) -> bool {
8434 if self.state.social_chat.composer_open() {
8435 self.state.social_chat.close_composer();
8436 return true;
8437 }
8438 if self.state.player_verbs.open {
8439 self.state.player_verbs.close();
8440 return true;
8441 }
8442 if self.state.whisper_pouch_ui.open {
8443 self.state.whisper_pouch_ui.open = false;
8444 return true;
8445 }
8446 if self.state.trade_ui.panel.is_some() {
8447 self.state.trade_ui.close();
8449 return true;
8450 }
8451 if self.state.show_rename_prompt {
8452 self.cancel_rename_prompt();
8453 return true;
8454 }
8455 if self.state.show_worker_rename {
8456 self.cancel_worker_rename();
8457 return true;
8458 }
8459 if self.state.show_destroy_picker {
8460 if self.state.destroy_confirm_pending {
8461 self.cancel_destroy_confirm();
8462 } else {
8463 self.close_destroy_picker();
8464 }
8465 return true;
8466 }
8467 if self.state.claim_mode.is_some() {
8468 self.cancel_claim_mode();
8469 return true;
8470 }
8471 if self.state.relocate_mode.is_some() {
8472 self.cancel_relocate_mode();
8473 return true;
8474 }
8475 if self.state.show_plant_menu {
8476 self.close_plant_menu();
8477 return true;
8478 }
8479 if self.state.show_farm_access {
8480 self.close_farm_access_panel();
8481 return true;
8482 }
8483 if self.state.sell_plot_confirm.is_some() {
8484 self.state.sell_plot_confirm = None;
8485 self.state.sell_plot_armed_at = None;
8486 self.state.push_log("Sell cancelled");
8487 return true;
8488 }
8489 if self.state.show_move_picker {
8490 self.close_move_picker();
8491 return true;
8492 }
8493 if self.state.show_rotation_editor {
8494 match self.state.rotation_editor.mode {
8495 RotationEditorMode::List => {
8496 self.state.show_rotation_editor = false;
8497 self.state.rotation_editor.reset();
8498 }
8499 RotationEditorMode::EditLabel => {
8500 self.state.rotation_editor.label_buffer.clear();
8501 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8502 }
8503 RotationEditorMode::PickAbility => {
8504 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8505 }
8506 RotationEditorMode::EditSequence => {
8507 self.state.rotation_editor.draft = None;
8508 self.state.rotation_editor.mode = RotationEditorMode::List;
8509 }
8510 }
8511 return true;
8512 }
8513 if self.state.show_inventory_menu {
8514 self.close_inventory_menu();
8515 return true;
8516 }
8517 if self.state.show_craft_menu {
8518 self.close_craft_menu();
8519 return true;
8520 }
8521 if self.state.show_plot_build_menu {
8522 self.close_plot_build_menu();
8523 return true;
8524 }
8525 if self.state.show_keychain_menu {
8526 self.close_keychain_menu();
8527 return true;
8528 }
8529 if self.state.show_quest_offer {
8530 self.quest_offer_decline();
8531 return true;
8532 }
8533 if self.state.show_shop_menu {
8534 return false;
8536 }
8537 if self.state.bank_panel.is_some() {
8538 return false;
8539 }
8540 if self.state.storage_panel.is_some() {
8541 return false;
8542 }
8543 if self.state.market_panel.is_some() {
8544 return false;
8545 }
8546 if self.state.show_npc_chat {
8547 return false;
8549 }
8550 if self.state.show_npc_verb_menu {
8551 self.state.show_npc_verb_menu = false;
8552 self.state.npc_verb_target = None;
8553 self.state.npc_verb_notice = None;
8554 return true;
8555 }
8556 if self.state.show_quest_menu {
8557 if self.state.quest_withdraw_confirm {
8558 self.state.quest_withdraw_confirm = false;
8559 } else {
8560 self.state.show_quest_menu = false;
8561 }
8562 return true;
8563 }
8564 if self.state.worker_route_editor.is_some() {
8565 if self.re_at_root_sheet() {
8567 let reopen = self.state.attending_worker_instance_id.clone();
8568 self.close_worker_route_editor();
8569 if let Some(id) = reopen {
8570 if let Some(idx) = self
8571 .state
8572 .hired_workers
8573 .iter()
8574 .position(|w| w.instance_id == id)
8575 {
8576 self.state.workers_menu_index = idx;
8577 self.state.show_workers_menu = true;
8578 }
8579 }
8580 } else {
8581 self.re_sheet_back();
8582 }
8583 return true;
8584 }
8585 if self.state.show_worker_give_picker {
8586 self.close_worker_give_picker();
8587 return true;
8588 }
8589 if self.state.show_worker_give_target_picker {
8590 self.close_worker_give_target_picker();
8591 return true;
8592 }
8593 if self.state.show_worker_take_picker {
8594 self.close_worker_take_picker();
8595 return true;
8596 }
8597 if self.state.show_worker_teach_picker {
8598 self.close_worker_teach_picker();
8599 return true;
8600 }
8601 if self.state.show_workers_menu {
8602 self.close_workers_menu_ui();
8603 return true;
8604 }
8605 if self.state.show_loadout_menu {
8606 self.state.show_loadout_menu = false;
8607 return true;
8608 }
8609 if self.state.show_stats {
8610 self.state.show_stats = false;
8611 return true;
8612 }
8613 if self.state.show_equip_menu {
8614 self.state.show_equip_menu = false;
8615 return true;
8616 }
8617 false
8618 }
8619
8620 pub fn toggle_stats(&mut self) {
8621 self.state.show_stats = !self.state.show_stats;
8622 if self.state.show_stats {
8623 self.state.character_sheet_tab = CharacterSheetTab::Character;
8624 self.state.show_craft_menu = false;
8625 self.state.show_shop_menu = false;
8626 self.state.shop_catalog = None;
8627 self.state.show_inventory_menu = false;
8628 self.state.show_equip_menu = false;
8629 }
8630 }
8631
8632 pub fn toggle_equip_menu(&mut self) {
8633 self.state.show_equip_menu = !self.state.show_equip_menu;
8634 if self.state.show_equip_menu {
8635 self.state.show_stats = false;
8636 self.state.show_craft_menu = false;
8637 self.state.show_shop_menu = false;
8638 self.state.shop_catalog = None;
8639 self.state.show_inventory_menu = false;
8640 self.state.show_loadout_menu = false;
8641 }
8642 }
8643
8644 pub fn cycle_character_sheet_tab(&mut self) {
8645 if self.state.show_stats {
8646 self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
8647 }
8648 }
8649
8650 pub fn set_ledger_period_digit(&mut self, c: char) {
8651 if self.state.show_stats {
8652 if let Some(p) = LedgerPeriod::from_digit(c) {
8653 self.state.ledger_period = p;
8654 self.state.character_sheet_tab = CharacterSheetTab::Ledger;
8655 }
8656 }
8657 }
8658
8659 pub fn cycle_ledger_period(&mut self) {
8660 if self.state.show_stats && self.state.character_sheet_tab == CharacterSheetTab::Ledger {
8661 self.state.ledger_period = self.state.ledger_period.cycle();
8662 }
8663 }
8664
8665 pub fn open_inventory_menu(&mut self) {
8666 self.state.show_inventory_menu = true;
8667 self.state.show_craft_menu = false;
8668 self.state.show_shop_menu = false;
8669 self.state.shop_catalog = None;
8670 self.state.show_stats = false;
8671 self.state.show_move_picker = false;
8672 self.state.move_picker = None;
8673 self.state.show_destroy_picker = false;
8674 self.state.destroy_confirm_pending = false;
8675 self.state.destroy_picker = None;
8676 self.state.show_rename_prompt = false;
8677 self.state.rename_plot_id = None;
8678 self.state.rename_buffer.clear();
8679 self.state.inventory_filter_focused = false;
8680 self.state.clamp_inventory_indices();
8681 }
8682
8683 pub fn close_inventory_menu(&mut self) {
8684 self.state.show_inventory_menu = false;
8685 self.state.show_move_picker = false;
8686 self.state.move_picker = None;
8687 self.close_grant_picker();
8688 self.state.show_destroy_picker = false;
8689 self.state.destroy_confirm_pending = false;
8690 self.state.destroy_picker = None;
8691 self.state.show_rename_prompt = false;
8692 self.state.rename_plot_id = None;
8693 self.state.rename_buffer.clear();
8694 self.state.inventory_filter_focused = false;
8695 }
8696
8697 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
8698 let Some(row) = self.state.inventory_selected_row() else {
8699 anyhow::bail!("inventory empty");
8700 };
8701 if GameState::is_property_deed_template(&row.stack.template_id) {
8702 let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
8703 anyhow::bail!("deed has no plot id");
8704 };
8705 let label = self
8706 .state
8707 .property_plots
8708 .iter()
8709 .find(|p| p.plot_id == plot_id)
8710 .map(|p| {
8711 if p.label.trim().is_empty() {
8712 p.plot_code.clone()
8713 } else {
8714 p.label.clone()
8715 }
8716 })
8717 .unwrap_or_else(|| {
8718 row.stack
8719 .display_name
8720 .clone()
8721 .unwrap_or_else(|| "plot".into())
8722 });
8723 self.state.rename_buffer = label;
8724 self.state.rename_plot_id = Some(plot_id);
8725 self.state.highlighted_plot_id = Some(plot_id);
8726 self.state.show_rename_prompt = true;
8727 self.state.show_worker_rename = false;
8728 self.state.show_move_picker = false;
8729 self.state.show_destroy_picker = false;
8730 self.state.destroy_confirm_pending = false;
8731 return Ok(());
8732 }
8733 if !self.state.row_is_renameable_container(&row) {
8734 anyhow::bail!("only storage containers or deeds can be renamed");
8735 }
8736 let current = row
8737 .stack
8738 .display_name
8739 .clone()
8740 .unwrap_or_else(|| row.stack.template_id.clone());
8741 self.state.rename_buffer = current;
8742 self.state.rename_plot_id = None;
8743 self.state.show_rename_prompt = true;
8744 self.state.show_worker_rename = false;
8745 self.state.show_move_picker = false;
8746 self.state.show_destroy_picker = false;
8747 self.state.destroy_confirm_pending = false;
8748 Ok(())
8749 }
8750
8751 pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
8753 let Some(plot) = self.state.my_plot_under_player().cloned() else {
8754 anyhow::bail!("stand on your plot to rename it");
8755 };
8756 let label = if plot.label.trim().is_empty() {
8757 plot.plot_code.clone()
8758 } else {
8759 plot.label.clone()
8760 };
8761 self.state.rename_buffer = label;
8762 self.state.rename_plot_id = Some(plot.plot_id);
8763 self.state.highlighted_plot_id = Some(plot.plot_id);
8764 self.state.show_rename_prompt = true;
8765 self.state.show_worker_rename = false;
8766 Ok(())
8767 }
8768
8769 pub fn cancel_rename_prompt(&mut self) {
8770 self.state.show_rename_prompt = false;
8771 self.state.rename_plot_id = None;
8772 self.state.rename_buffer.clear();
8773 }
8774
8775 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
8776 let name = self.state.rename_buffer.trim().to_string();
8777 if name.is_empty() {
8778 anyhow::bail!("name cannot be empty");
8779 }
8780 if let Some(plot_id) = self.state.rename_plot_id {
8781 if name.chars().count() > 48 {
8782 anyhow::bail!("label must be 1–48 characters");
8783 }
8784 self.seq += 1;
8785 self.session
8786 .submit_intent(Intent::RenamePropertyPlot {
8787 entity_id: self.state.entity_id,
8788 plot_id,
8789 label: name,
8790 seq: self.seq,
8791 })
8792 .await?;
8793 self.state.intents_sent += 1;
8794 self.state.show_rename_prompt = false;
8795 self.state.rename_plot_id = None;
8796 self.state.rename_buffer.clear();
8797 return Ok(());
8798 }
8799 if name.chars().count() > 32 {
8800 anyhow::bail!("name must be 1–32 characters");
8801 }
8802 let Some(row) = self.state.inventory_selected_row() else {
8803 anyhow::bail!("inventory empty");
8804 };
8805 let Some(instance_id) = row.stack.item_instance_id else {
8806 anyhow::bail!("item has no instance id");
8807 };
8808 self.seq += 1;
8809 self.session
8810 .submit_intent(Intent::RenameContainer {
8811 entity_id: self.state.entity_id,
8812 item_instance_id: instance_id,
8813 location: row.from.clone(),
8814 name,
8815 seq: self.seq,
8816 })
8817 .await?;
8818 self.state.intents_sent += 1;
8819 self.state.show_rename_prompt = false;
8820 self.state.rename_buffer.clear();
8821 Ok(())
8822 }
8823
8824 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
8825 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8826 anyhow::bail!("no worker selected");
8827 };
8828 self.state.rename_buffer = worker.label.clone();
8829 self.state.show_worker_rename = true;
8830 self.state.show_rename_prompt = false;
8831 Ok(())
8832 }
8833
8834 pub fn cancel_worker_rename(&mut self) {
8835 self.state.show_worker_rename = false;
8836 self.state.rename_buffer.clear();
8837 }
8838
8839 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
8840 let name = self.state.rename_buffer.trim().to_string();
8841 if name.is_empty() {
8842 anyhow::bail!("name cannot be empty");
8843 }
8844 if name.chars().count() > 32 {
8845 anyhow::bail!("name must be 1–32 characters");
8846 }
8847 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8848 anyhow::bail!("no worker selected");
8849 };
8850 let worker_instance_id = worker.instance_id.clone();
8851 self.seq += 1;
8852 self.session
8853 .submit_intent(Intent::RenameHiredWorker {
8854 entity_id: self.state.entity_id,
8855 worker_instance_id: worker_instance_id.clone(),
8856 name: name.clone(),
8857 seq: self.seq,
8858 })
8859 .await?;
8860 self.state.intents_sent += 1;
8861 if let Some(w) = self
8862 .state
8863 .hired_workers
8864 .iter_mut()
8865 .find(|w| w.instance_id == worker_instance_id)
8866 {
8867 w.label = name.clone();
8868 }
8869 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8870 if ed.worker_instance_id == worker_instance_id {
8871 ed.worker_label = name.clone();
8872 }
8873 }
8874 self.state.show_worker_rename = false;
8875 self.state.rename_buffer.clear();
8876 self.state.push_log(format!("Renamed worker to \"{name}\""));
8877 Ok(())
8878 }
8879
8880 pub fn toggle_inventory_menu(&mut self) {
8881 if self.state.show_inventory_menu {
8882 self.close_inventory_menu();
8883 } else {
8884 self.open_inventory_menu();
8885 }
8886 }
8887
8888 pub fn inventory_menu_move(&mut self, delta: i32) {
8890 if self.state.show_grant_picker {
8891 let Some(picker) = self.state.grant_picker.as_ref() else {
8892 return;
8893 };
8894 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8895 let filter = picker.filter.clone();
8896 let n = labels.len();
8897 if n == 0 {
8898 return;
8899 }
8900 self.state.grant_picker_index =
8901 step_filtered_index(self.state.grant_picker_index, delta, n, |i| {
8902 list_label_matches(&labels[i], &filter)
8903 });
8904 return;
8905 }
8906 if self.state.show_move_picker {
8907 let Some(picker) = self.state.move_picker.as_ref() else {
8908 return;
8909 };
8910 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8911 let filter = picker.filter.clone();
8912 let n = labels.len();
8913 if n == 0 {
8914 return;
8915 }
8916 self.state.move_picker_index =
8917 step_filtered_index(self.state.move_picker_index, delta, n, |i| {
8918 list_label_matches(&labels[i], &filter)
8919 });
8920 self.state.clamp_move_picker_quantity();
8921 return;
8922 }
8923 let n = self.state.inventory_selectable_rows().len();
8924 if n == 0 {
8925 return;
8926 }
8927 let idx = self.state.inventory_menu_index as i32;
8928 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8929 }
8930
8931 pub fn inventory_menu_page(&mut self, pages: i32) {
8933 if self.state.show_grant_picker {
8934 let Some(picker) = self.state.grant_picker.as_ref() else {
8935 return;
8936 };
8937 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8938 let filter = picker.filter.clone();
8939 let n = labels.len();
8940 self.state.grant_picker_index =
8941 page_filtered_index(self.state.grant_picker_index, pages, n, |i| {
8942 list_label_matches(&labels[i], &filter)
8943 });
8944 return;
8945 }
8946 if self.state.show_move_picker {
8947 let Some(picker) = self.state.move_picker.as_ref() else {
8948 return;
8949 };
8950 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8951 let filter = picker.filter.clone();
8952 let n = labels.len();
8953 self.state.move_picker_index =
8954 page_filtered_index(self.state.move_picker_index, pages, n, |i| {
8955 list_label_matches(&labels[i], &filter)
8956 });
8957 self.state.clamp_move_picker_quantity();
8958 return;
8959 }
8960 let n = self.state.inventory_selectable_rows().len();
8961 self.state.inventory_menu_index =
8962 page_list_index(self.state.inventory_menu_index, pages, n);
8963 }
8964
8965 pub fn cycle_inventory_tab(&mut self, forward: bool) {
8966 if self.state.show_move_picker
8967 || self.state.show_grant_picker
8968 || self.state.show_destroy_picker
8969 || self.state.show_rename_prompt
8970 || self.state.inventory_filter_focused
8971 {
8972 return;
8973 }
8974 self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
8975 self.state.inventory_menu_index = 0;
8976 self.state.clamp_inventory_indices();
8977 }
8978
8979 pub fn focus_inventory_filter(&mut self) {
8980 if self.state.show_grant_picker {
8981 if let Some(p) = self.state.grant_picker.as_mut() {
8982 p.filter_focused = true;
8983 }
8984 return;
8985 }
8986 if self.state.show_move_picker {
8987 if let Some(p) = self.state.move_picker.as_mut() {
8988 p.filter_focused = true;
8989 }
8990 return;
8991 }
8992 self.state.inventory_filter_focused = true;
8993 }
8994
8995 pub fn set_inventory_filter(&mut self, filter: String) {
8996 self.state.inventory_filter = filter;
8997 self.state.inventory_menu_index = 0;
8998 self.state.clamp_inventory_indices();
8999 }
9000
9001 pub fn append_inventory_filter_char(&mut self, ch: char) {
9002 if !is_list_filter_char(ch) {
9003 return;
9004 }
9005 if self.state.show_grant_picker {
9006 if let Some(p) = self.state.grant_picker.as_mut() {
9007 if p.filter_focused {
9008 p.filter.push(ch);
9009 self.state.grant_picker_index = 0;
9010 }
9011 }
9012 return;
9013 }
9014 if self.state.show_move_picker {
9015 if let Some(p) = self.state.move_picker.as_mut() {
9016 if p.filter_focused {
9017 p.filter.push(ch);
9018 self.state.move_picker_index = 0;
9019 self.state.clamp_move_picker_quantity();
9020 }
9021 }
9022 return;
9023 }
9024 if !self.state.inventory_filter_focused {
9025 return;
9026 }
9027 self.state.inventory_filter.push(ch);
9028 self.state.inventory_menu_index = 0;
9029 self.state.clamp_inventory_indices();
9030 }
9031
9032 pub fn inventory_filter_backspace(&mut self) {
9033 if self.state.show_grant_picker {
9034 if let Some(p) = self.state.grant_picker.as_mut() {
9035 if p.filter_focused {
9036 p.filter.pop();
9037 self.state.grant_picker_index = 0;
9038 }
9039 }
9040 return;
9041 }
9042 if self.state.show_move_picker {
9043 if let Some(p) = self.state.move_picker.as_mut() {
9044 if p.filter_focused {
9045 p.filter.pop();
9046 self.state.move_picker_index = 0;
9047 self.state.clamp_move_picker_quantity();
9048 }
9049 }
9050 return;
9051 }
9052 if !self.state.inventory_filter_focused {
9053 return;
9054 }
9055 self.state.inventory_filter.pop();
9056 self.state.inventory_menu_index = 0;
9057 self.state.clamp_inventory_indices();
9058 }
9059
9060 pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
9062 if self.state.show_grant_picker {
9063 if let Some(p) = self.state.grant_picker.as_mut() {
9064 if p.filter_focused {
9065 if !p.filter.is_empty() {
9066 p.filter.clear();
9067 self.state.grant_picker_index = 0;
9068 } else {
9069 p.filter_focused = false;
9070 }
9071 return true;
9072 }
9073 if !p.filter.is_empty() {
9074 p.filter.clear();
9075 self.state.grant_picker_index = 0;
9076 return true;
9077 }
9078 }
9079 return false;
9080 }
9081 if self.state.show_move_picker {
9082 if let Some(p) = self.state.move_picker.as_mut() {
9083 if p.filter_focused {
9084 if !p.filter.is_empty() {
9085 p.filter.clear();
9086 self.state.move_picker_index = 0;
9087 self.state.clamp_move_picker_quantity();
9088 } else {
9089 p.filter_focused = false;
9090 }
9091 return true;
9092 }
9093 if !p.filter.is_empty() {
9094 p.filter.clear();
9095 self.state.move_picker_index = 0;
9096 self.state.clamp_move_picker_quantity();
9097 return true;
9098 }
9099 }
9100 return false;
9101 }
9102 if self.state.inventory_filter_focused {
9103 if !self.state.inventory_filter.is_empty() {
9104 self.state.inventory_filter.clear();
9105 self.state.inventory_menu_index = 0;
9106 self.state.clamp_inventory_indices();
9107 } else {
9108 self.state.inventory_filter_focused = false;
9109 }
9110 return true;
9111 }
9112 if !self.state.inventory_filter.is_empty() {
9113 self.state.inventory_filter.clear();
9114 self.state.inventory_menu_index = 0;
9115 self.state.clamp_inventory_indices();
9116 return true;
9117 }
9118 false
9119 }
9120
9121 pub fn craft_menu_page(&mut self, pages: i32) {
9122 let n = self.state.craft_filtered_indices().len();
9123 self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
9124 self.state.clamp_craft_batch_quantity();
9125 }
9126
9127 pub fn shop_menu_page(&mut self, pages: i32) {
9128 let n = self.state.shop_list_len();
9129 self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
9130 self.state.clamp_shop_quantity();
9131 }
9132
9133 pub fn workers_menu_page(&mut self, pages: i32) {
9134 let n = self.state.hired_workers.len();
9135 self.state.workers_menu_index = page_list_index(self.state.workers_menu_index, pages, n);
9136 }
9137
9138 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
9143 if self.state.show_destroy_picker {
9144 if self.state.destroy_confirm_pending {
9145 return self.confirm_destroy_item().await;
9146 }
9147 return self.request_destroy_confirm();
9148 }
9149 if self.state.show_grant_picker {
9150 return self.confirm_grant_picker().await;
9151 }
9152 if self.state.show_move_picker {
9153 return self.confirm_move_picker().await;
9154 }
9155 let Some(row) = self.state.inventory_selected_row() else {
9156 anyhow::bail!("inventory empty");
9157 };
9158 if row.is_equip_shell {
9159 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
9160 anyhow::bail!("not a worn item");
9161 };
9162 return self.equip_worn(slot, None).await;
9163 }
9164 if row.is_chest_shell {
9165 return self.open_chest_pickup_picker();
9166 }
9167 let template_id = row.stack.template_id.clone();
9168 let instance_id = row.stack.item_instance_id;
9169 let category = self.state.inventory_item_category(&template_id);
9170 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9171
9172 if category == Some("weapon") {
9173 return self.equip_mainhand(Some(template_id)).await;
9174 }
9175 if category == Some("lodging") && on_person {
9176 if let Some(inst) = instance_id {
9177 return self.place_container(inst).await;
9178 }
9179 }
9180 if on_person {
9182 if let Some(inst) = instance_id {
9183 if row.stack.world_placeable == Some(true) {
9184 return self.place_container(inst).await;
9185 }
9186 }
9187 }
9188 if (category == Some("container") || category == Some("armor")) && on_person {
9189 if let Some(inst) = instance_id {
9190 let world_placeable =
9191 row.stack.world_placeable == Some(true) || template_id.contains("chest");
9192 if world_placeable {
9193 return self.place_container(inst).await;
9194 }
9195 if let Some(slot) = guess_body_slot(&template_id) {
9199 return self.equip_worn(slot, Some(inst)).await;
9200 }
9201 }
9202 }
9203 self.open_move_picker()
9207 }
9208
9209 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
9211 let Some(row) = self.state.inventory_selected_row() else {
9212 anyhow::bail!("inventory empty");
9213 };
9214 if row.from != flatland_protocol::InventoryLocation::Root {
9215 anyhow::bail!("select a consumable on your person");
9216 }
9217 if GameState::stack_is_item_grant(&row.stack) {
9218 return self.open_grant_target_picker();
9219 }
9220 if GameState::is_property_deed_template(&row.stack.template_id) {
9221 return self.open_move_picker();
9222 }
9223 let category = self.state.inventory_item_category(&row.stack.template_id);
9224 if category != Some("consumable") && !GameState::stack_is_serving(&row.stack) {
9225 anyhow::bail!("selected item is not usable");
9226 }
9227 self.use_item(&row.stack.template_id).await
9228 }
9229
9230 pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
9232 let Some(row) = self.state.inventory_selected_row() else {
9233 anyhow::bail!("inventory empty");
9234 };
9235 if row.from != flatland_protocol::InventoryLocation::Root {
9236 anyhow::bail!("select a grant item on your person");
9237 }
9238 if !GameState::stack_is_item_grant(&row.stack) {
9239 anyhow::bail!("selected item does not grant onto gear");
9240 }
9241 let Some(grant_instance_id) = row.stack.item_instance_id else {
9242 anyhow::bail!("grant has no instance id");
9243 };
9244 let effect_id = GameState::grant_effect_id(&row.stack)
9245 .unwrap_or("?")
9246 .to_string();
9247 let mode = GameState::grant_mode(&row.stack).to_string();
9248 let options = self.state.grant_target_options(&row.stack);
9249 if options.is_empty() {
9250 anyhow::bail!("no valid gear to apply {effect_id} to");
9251 }
9252 let grant_label = row
9253 .stack
9254 .display_name
9255 .clone()
9256 .unwrap_or_else(|| row.stack.template_id.clone());
9257 self.state.show_grant_picker = true;
9258 self.state.grant_picker_index = 0;
9259 self.state.grant_picker = Some(GrantTargetPicker {
9260 grant_instance_id,
9261 grant_label,
9262 effect_id,
9263 mode,
9264 options,
9265 filter: String::new(),
9266 filter_focused: false,
9267 });
9268 Ok(())
9269 }
9270
9271 pub fn close_grant_picker(&mut self) {
9272 self.state.show_grant_picker = false;
9273 self.state.grant_picker = None;
9274 self.state.grant_picker_index = 0;
9275 }
9276
9277 pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
9278 let Some(picker) = self.state.grant_picker.clone() else {
9279 self.close_grant_picker();
9280 return Ok(());
9281 };
9282 let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
9283 self.close_grant_picker();
9284 return Ok(());
9285 };
9286 self.close_grant_picker();
9287 self.use_grant(picker.grant_instance_id, opt.target_instance_id)
9288 .await?;
9289 self.state
9290 .push_log(format!("Applying {} onto {}…", picker.effect_id, opt.label));
9291 Ok(())
9292 }
9293
9294 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
9298 let Some(row) = self.state.inventory_selected_row() else {
9299 anyhow::bail!("inventory empty");
9300 };
9301 if row.is_equip_shell {
9302 anyhow::bail!("this is a worn bag — press Enter to unequip it");
9303 }
9304 if row.is_chest_shell {
9305 return self.open_chest_pickup_picker();
9306 }
9307 let Some(instance_id) = row.stack.item_instance_id else {
9308 anyhow::bail!("item has no instance id");
9309 };
9310 let mut options = self.state.move_destinations_for(
9311 &row.from,
9312 row.from_parent_instance_id,
9313 row.stack.item_instance_id,
9314 &row.stack.template_id,
9315 );
9316 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9317 let category = self.state.inventory_item_category(&row.stack.template_id);
9318 if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
9319 if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
9320 options.insert(
9321 0,
9322 MoveOption {
9323 label: "Sell plot to crown…".into(),
9324 kind: MoveOptionKind::SellPlotToCrown { plot_id },
9325 },
9326 );
9327 }
9328 }
9329 if on_person && category == Some("consumable") {
9330 if GameState::stack_is_item_grant(&row.stack) {
9331 options.insert(
9332 0,
9333 MoveOption {
9334 label: "Apply onto gear…".into(),
9335 kind: MoveOptionKind::GrantApply,
9336 },
9337 );
9338 } else {
9339 let study = GameState::stack_is_blueprint_scroll(&row.stack);
9340 options.insert(
9341 0,
9342 MoveOption {
9343 label: if study {
9344 "Study".into()
9345 } else {
9346 "Use (eat / drink)".into()
9347 },
9348 kind: MoveOptionKind::Use,
9349 },
9350 );
9351 }
9352 } else if on_person && GameState::stack_is_serving(&row.stack) {
9353 let label = if GameState::stack_is_food_serving(&row.stack) {
9354 "Use (eat)"
9355 } else {
9356 "Use (fill / drink)"
9357 };
9358 options.insert(
9359 0,
9360 MoveOption {
9361 label: label.into(),
9362 kind: MoveOptionKind::Use,
9363 },
9364 );
9365 }
9366 let item_label = row
9367 .stack
9368 .display_name
9369 .clone()
9370 .unwrap_or_else(|| row.stack.template_id.clone());
9371 let initial_qty = if row.stack.quantity > 1 {
9374 1
9375 } else {
9376 row.stack.quantity
9377 };
9378 self.state.move_picker = Some(MovePicker {
9379 item_instance_id: instance_id,
9380 from: row.from,
9381 item_label,
9382 template_id: row.stack.template_id.clone(),
9383 stack_quantity: row.stack.quantity,
9384 quantity: initial_qty.max(1),
9385 options,
9386 filter: String::new(),
9387 filter_focused: false,
9388 });
9389 self.state.move_picker_index = 0;
9390 self.state.show_move_picker = true;
9391 self.state.show_destroy_picker = false;
9392 self.state.destroy_confirm_pending = false;
9393 self.state.destroy_picker = None;
9394 self.state.clamp_move_picker_quantity();
9395 Ok(())
9396 }
9397
9398 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
9400 let Some(row) = self.state.inventory_selected_row() else {
9401 anyhow::bail!("inventory empty");
9402 };
9403 if !row.is_chest_shell {
9404 anyhow::bail!("not a placed chest");
9405 }
9406 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
9407 anyhow::bail!("not a placed chest");
9408 };
9409 let Some(instance_id) = row.stack.item_instance_id else {
9410 anyhow::bail!("chest has no instance id");
9411 };
9412 let chest = self
9413 .state
9414 .placed_containers
9415 .iter()
9416 .find(|c| c.id == *container_id)
9417 .cloned()
9418 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9419 let (px, py) = self.state.player_position();
9420 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9421 anyhow::bail!("too far from {}", chest.display_name);
9422 }
9423 if chest.locked && !chest.accessible {
9424 anyhow::bail!(
9425 "need the matching key for {} before picking it up",
9426 chest.display_name
9427 );
9428 }
9429 let options = self.state.chest_pickup_destinations(container_id);
9430 let item_label = row
9431 .stack
9432 .display_name
9433 .clone()
9434 .unwrap_or_else(|| row.stack.template_id.clone());
9435 self.state.move_picker = Some(MovePicker {
9436 item_instance_id: instance_id,
9437 from: row.from.clone(),
9438 item_label,
9439 template_id: row.stack.template_id.clone(),
9440 stack_quantity: 1,
9441 quantity: 1,
9442 options,
9443 filter: String::new(),
9444 filter_focused: false,
9445 });
9446 self.state.move_picker_index = 0;
9447 self.state.show_move_picker = true;
9448 self.state.show_destroy_picker = false;
9449 self.state.destroy_confirm_pending = false;
9450 self.state.destroy_picker = None;
9451 Ok(())
9452 }
9453
9454 pub fn close_move_picker(&mut self) {
9455 self.state.show_move_picker = false;
9456 self.state.move_picker = None;
9457 }
9458
9459 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
9460 self.state.move_picker_adjust_quantity(delta);
9461 }
9462
9463 pub fn move_picker_set_quantity_max(&mut self) {
9464 self.state.move_picker_set_quantity_max();
9465 }
9466
9467 pub fn move_picker_set_quantity_min(&mut self) {
9468 self.state.move_picker_set_quantity_min();
9469 }
9470
9471 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
9472 self.state.destroy_picker_adjust_quantity(delta);
9473 }
9474
9475 pub fn destroy_picker_set_quantity_max(&mut self) {
9476 self.state.destroy_picker_set_quantity_max();
9477 }
9478
9479 pub fn destroy_picker_set_quantity_min(&mut self) {
9480 self.state.destroy_picker_set_quantity_min();
9481 }
9482
9483 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
9484 let Some(picker) = self.state.move_picker.clone() else {
9485 self.close_move_picker();
9486 return Ok(());
9487 };
9488 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
9489 self.close_move_picker();
9490 return Ok(());
9491 };
9492 match option.kind {
9493 MoveOptionKind::Cancel => {
9494 self.close_move_picker();
9495 }
9496 MoveOptionKind::Use => {
9497 self.close_move_picker();
9498 self.use_item(&picker.template_id).await?;
9499 }
9500 MoveOptionKind::GrantApply => {
9501 self.close_move_picker();
9502 self.open_grant_target_picker()?;
9503 }
9504 MoveOptionKind::SellPlotToCrown { plot_id } => {
9505 self.close_move_picker();
9506 self.confirm_sell_plot_to_crown(plot_id).await?;
9507 }
9508 MoveOptionKind::RelocatePlaced { container_id } => {
9509 self.close_move_picker();
9510 self.state.show_inventory_menu = false;
9511 self.begin_relocate_container(&container_id)?;
9512 }
9513 MoveOptionKind::Drop => {
9514 self.close_move_picker();
9515 if self
9516 .state
9517 .hand_equipped_instance_ids()
9518 .contains(&picker.item_instance_id)
9519 {
9520 anyhow::bail!("unequip that item first");
9521 }
9522 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
9523 if self.state.deed_bound(&stack) {
9524 anyhow::bail!(
9525 "cannot drop a property deed — store it or trade it to another player"
9526 );
9527 }
9528 if self.state.key_drop_blocked(&stack) {
9529 anyhow::bail!("cannot drop the key while its chest is locked");
9530 }
9531 }
9532 self.drop_item(picker.item_instance_id, picker.from).await?;
9533 self.state
9534 .push_log(format!("Dropped {}", picker.item_label));
9535 }
9536 MoveOptionKind::PickupPlaced {
9537 container_id,
9538 nest_location,
9539 nest_parent_instance_id,
9540 } => {
9541 self.close_move_picker();
9542 self.pickup_container(container_id.clone()).await?;
9543 let nest_into_bag = nest_parent_instance_id.is_some()
9544 || !matches!(nest_location, flatland_protocol::InventoryLocation::Root);
9545 if nest_into_bag {
9546 self.move_item(
9547 picker.item_instance_id,
9548 flatland_protocol::InventoryLocation::Root,
9549 nest_location,
9550 nest_parent_instance_id,
9551 None,
9552 )
9553 .await?;
9554 self.state
9555 .push_log(format!("Picked up {} into bag", picker.item_label));
9556 } else {
9557 self.state
9558 .push_log(format!("Picked up {}", picker.item_label));
9559 }
9560 }
9561 MoveOptionKind::Move {
9562 location,
9563 parent_instance_id,
9564 } => {
9565 self.close_move_picker();
9566 let qty = if picker.quantity >= picker.stack_quantity {
9567 None
9568 } else {
9569 Some(picker.quantity)
9570 };
9571 self.move_item(
9572 picker.item_instance_id,
9573 picker.from,
9574 location,
9575 parent_instance_id,
9576 qty,
9577 )
9578 .await?;
9579 let moved = qty.unwrap_or(picker.stack_quantity);
9580 if moved >= picker.stack_quantity {
9581 self.state.push_log(format!("Moved {}", picker.item_label));
9582 } else {
9583 self.state.push_log(format!(
9584 "Moved {} ×{} of {}",
9585 picker.item_label, moved, picker.stack_quantity
9586 ));
9587 }
9588 }
9589 }
9590 Ok(())
9591 }
9592
9593 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
9597 let Some(row) = self.state.inventory_selected_row() else {
9598 anyhow::bail!("inventory empty");
9599 };
9600 if row.is_equip_shell {
9601 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
9602 }
9603 if row.is_chest_shell {
9604 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
9605 }
9606 let Some(inst) = row.stack.item_instance_id else {
9607 anyhow::bail!("item has no instance id");
9608 };
9609 if self.state.hand_equipped_instance_ids().contains(&inst) {
9610 anyhow::bail!("unequip that item first");
9611 }
9612 if self.state.deed_bound(&row.stack) {
9613 anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
9614 }
9615 if self.state.key_drop_blocked(&row.stack) {
9616 anyhow::bail!("cannot drop the key while its chest is locked");
9617 }
9618 let label = row
9619 .stack
9620 .display_name
9621 .clone()
9622 .unwrap_or_else(|| row.stack.template_id.clone());
9623 let placeable = row.stack.world_placeable == Some(true)
9624 || row.from == flatland_protocol::InventoryLocation::Root
9625 && matches!(
9626 self.state.inventory_item_category(&row.stack.template_id).as_deref(),
9627 Some("lodging")
9628 );
9629 if placeable && row.from == flatland_protocol::InventoryLocation::Root {
9630 self.place_container(inst).await?;
9631 self.state.push_log(format!("Placed {label}"));
9632 return Ok(());
9633 }
9634 self.drop_item(inst, row.from).await?;
9635 self.state.push_log(format!("Dropped {label}"));
9636 Ok(())
9637 }
9638
9639 pub async fn drop_item(
9640 &mut self,
9641 item_instance_id: uuid::Uuid,
9642 from: flatland_protocol::InventoryLocation,
9643 ) -> anyhow::Result<()> {
9644 self.seq += 1;
9645 self.session
9646 .submit_intent(Intent::DropItem {
9647 entity_id: self.state.entity_id,
9648 item_instance_id,
9649 from,
9650 seq: self.seq,
9651 })
9652 .await?;
9653 self.state.intents_sent += 1;
9654 Ok(())
9655 }
9656
9657 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
9659 let Some(row) = self.state.inventory_selected_row() else {
9660 anyhow::bail!("inventory empty");
9661 };
9662 if row.is_equip_shell {
9663 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
9664 }
9665 if row.is_chest_shell {
9666 anyhow::bail!("can't destroy a placed chest from the inventory list");
9667 }
9668 let Some(instance_id) = row.stack.item_instance_id else {
9669 anyhow::bail!("item has no instance id");
9670 };
9671 if self
9672 .state
9673 .hand_equipped_instance_ids()
9674 .contains(&instance_id)
9675 {
9676 anyhow::bail!("unequip that item first");
9677 }
9678 if self.state.deed_bound(&row.stack) {
9679 anyhow::bail!(
9680 "cannot destroy a property deed — store it or trade it to another player"
9681 );
9682 }
9683 if self.state.key_drop_blocked(&row.stack) {
9684 anyhow::bail!("cannot destroy the key while its chest is locked");
9685 }
9686 let item_label = row
9687 .stack
9688 .display_name
9689 .clone()
9690 .unwrap_or_else(|| row.stack.template_id.clone());
9691 self.state.destroy_picker = Some(DestroyPicker {
9692 item_instance_id: instance_id,
9693 from: row.from,
9694 item_label,
9695 stack_quantity: row.stack.quantity,
9696 quantity: row.stack.quantity,
9697 });
9698 self.state.destroy_confirm_pending = false;
9699 self.state.show_destroy_picker = true;
9700 self.state.show_move_picker = false;
9701 self.state.move_picker = None;
9702 Ok(())
9703 }
9704
9705 pub fn close_destroy_picker(&mut self) {
9706 self.state.show_destroy_picker = false;
9707 self.state.destroy_confirm_pending = false;
9708 self.state.destroy_picker = None;
9709 }
9710
9711 pub fn cancel_destroy_confirm(&mut self) {
9712 self.state.destroy_confirm_pending = false;
9713 }
9714
9715 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
9716 if self.state.destroy_picker.is_none() {
9717 self.close_destroy_picker();
9718 return Ok(());
9719 }
9720 self.state.destroy_confirm_pending = true;
9721 Ok(())
9722 }
9723
9724 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
9725 let Some(picker) = self.state.destroy_picker.clone() else {
9726 self.close_destroy_picker();
9727 return Ok(());
9728 };
9729 let qty = if picker.quantity >= picker.stack_quantity {
9730 None
9731 } else {
9732 Some(picker.quantity)
9733 };
9734 self.destroy_item(picker.item_instance_id, picker.from, qty)
9735 .await?;
9736 let destroyed = qty.unwrap_or(picker.stack_quantity);
9737 if destroyed >= picker.stack_quantity {
9738 self.state
9739 .push_log(format!("Destroyed {}", picker.item_label));
9740 } else {
9741 self.state.push_log(format!(
9742 "Destroyed {} ×{} of {}",
9743 picker.item_label, destroyed, picker.stack_quantity
9744 ));
9745 }
9746 self.close_destroy_picker();
9747 Ok(())
9748 }
9749
9750 pub async fn destroy_item(
9751 &mut self,
9752 item_instance_id: uuid::Uuid,
9753 from: flatland_protocol::InventoryLocation,
9754 quantity: Option<u32>,
9755 ) -> anyhow::Result<()> {
9756 self.seq += 1;
9757 self.session
9758 .submit_intent(Intent::DestroyItem {
9759 entity_id: self.state.entity_id,
9760 item_instance_id,
9761 from,
9762 quantity,
9763 seq: self.seq,
9764 })
9765 .await?;
9766 self.state.intents_sent += 1;
9767 Ok(())
9768 }
9769
9770 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
9772 if let Some(row) = self.state.inventory_selected_row() {
9773 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
9774 return self.toggle_placed_chest_lock(container_id).await;
9775 }
9776 }
9777 self.toggle_nearby_chest_lock().await
9778 }
9779
9780 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
9781 let chest = self
9782 .state
9783 .placed_containers
9784 .iter()
9785 .find(|c| c.id == container_id)
9786 .cloned()
9787 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9788 let (px, py) = self.state.player_position();
9789 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9790 anyhow::bail!("too far from {}", chest.display_name);
9791 }
9792 if !chest.accessible && chest.locked {
9793 anyhow::bail!(
9794 "need the matching key for {} (each crafted chest has its own key)",
9795 chest.display_name
9796 );
9797 }
9798 let lock = !chest.locked;
9799 self.set_container_locked(
9800 flatland_protocol::InventoryLocation::Placed {
9801 container_id: chest.id.clone(),
9802 },
9803 lock,
9804 )
9805 .await?;
9806 self.state.push_log(if lock {
9807 format!("Locked {}", chest.display_name)
9808 } else {
9809 format!("Unlocked {}", chest.display_name)
9810 });
9811 Ok(())
9812 }
9813
9814 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
9816 let chest = self
9817 .state
9818 .nearest_placed_container(CONTAINER_RANGE_M)
9819 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
9820 self.toggle_placed_chest_lock(&chest.id).await
9821 }
9822
9823 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
9824 self.equip_mainhand(None).await
9825 }
9826
9827 pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
9828 if !self.state.is_alive() {
9829 anyhow::bail!("you are dead");
9830 }
9831 self.seq += 1;
9832 self.session
9833 .submit_intent(Intent::EquipOffhand {
9834 entity_id: self.state.entity_id,
9835 template_id,
9836 instance_id: None,
9837 seq: self.seq,
9838 })
9839 .await?;
9840 self.state.intents_sent += 1;
9841 Ok(())
9842 }
9843
9844 pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
9845 self.equip_offhand(None).await
9846 }
9847
9848 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
9849 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
9850 for slot in slots {
9851 self.equip_worn(slot, None).await?;
9852 }
9853 Ok(())
9854 }
9855
9856 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
9857 let (px, py) = self.state.player_position();
9858 let in_range: Vec<_> = self
9859 .state
9860 .placed_containers
9861 .iter()
9862 .filter(|c| self.state.placed_container_in_current_space(c))
9863 .filter(|c| (c.x - px).hypot(c.y - py) <= 2.0)
9864 .collect();
9865 let nearest_free = in_range
9866 .iter()
9867 .copied()
9868 .filter(|c| !self.state.lodging_is_occupied(&c.id))
9869 .min_by(|a, b| {
9870 let da = (a.x - px).hypot(a.y - py);
9871 let db = (b.x - px).hypot(b.y - py);
9872 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
9873 })
9874 .cloned();
9875 if let Some(chest) = nearest_free {
9876 return self.pickup_container(chest.id).await;
9877 }
9878 if in_range
9879 .iter()
9880 .any(|c| self.state.lodging_is_occupied(&c.id))
9881 {
9882 anyhow::bail!("dismiss or reassign workers before picking up lodging");
9883 }
9884 if in_range.is_empty() {
9885 anyhow::bail!("no chest nearby");
9886 }
9887 anyhow::bail!("too far from chest");
9888 }
9889
9890 pub async fn equip_worn(
9891 &mut self,
9892 slot: BodySlot,
9893 instance_id: Option<uuid::Uuid>,
9894 ) -> anyhow::Result<()> {
9895 self.seq += 1;
9896 self.session
9897 .submit_intent(Intent::EquipWorn {
9898 entity_id: self.state.entity_id,
9899 slot,
9900 instance_id,
9901 seq: self.seq,
9902 })
9903 .await?;
9904 self.state.intents_sent += 1;
9905 Ok(())
9906 }
9907
9908 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
9909 self.seq += 1;
9910 self.session
9911 .submit_intent(Intent::PlaceContainer {
9912 entity_id: self.state.entity_id,
9913 item_instance_id,
9914 seq: self.seq,
9915 })
9916 .await?;
9917 self.state.intents_sent += 1;
9918 Ok(())
9919 }
9920
9921 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
9922 self.seq += 1;
9923 self.session
9924 .submit_intent(Intent::PickupContainer {
9925 entity_id: self.state.entity_id,
9926 container_id,
9927 seq: self.seq,
9928 })
9929 .await?;
9930 self.state.intents_sent += 1;
9931 Ok(())
9932 }
9933
9934 pub async fn move_item(
9935 &mut self,
9936 item_instance_id: uuid::Uuid,
9937 from: flatland_protocol::InventoryLocation,
9938 to: flatland_protocol::InventoryLocation,
9939 to_parent_instance_id: Option<uuid::Uuid>,
9940 quantity: Option<u32>,
9941 ) -> anyhow::Result<()> {
9942 self.seq += 1;
9943 self.session
9944 .submit_intent(Intent::MoveItem {
9945 entity_id: self.state.entity_id,
9946 item_instance_id,
9947 from,
9948 to,
9949 to_parent_instance_id,
9950 quantity,
9951 seq: self.seq,
9952 })
9953 .await?;
9954 self.state.intents_sent += 1;
9955 Ok(())
9956 }
9957
9958 pub async fn set_container_locked(
9959 &mut self,
9960 location: flatland_protocol::InventoryLocation,
9961 locked: bool,
9962 ) -> anyhow::Result<()> {
9963 self.seq += 1;
9964 self.session
9965 .submit_intent(Intent::SetContainerLocked {
9966 entity_id: self.state.entity_id,
9967 location,
9968 locked,
9969 seq: self.seq,
9970 })
9971 .await?;
9972 self.state.intents_sent += 1;
9973 Ok(())
9974 }
9975
9976 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
9977 if !self.state.is_alive() {
9978 anyhow::bail!("you are dead");
9979 }
9980 self.seq += 1;
9981 self.session
9982 .submit_intent(Intent::Use {
9983 entity_id: self.state.entity_id,
9984 template_id: template_id.to_string(),
9985 seq: self.seq,
9986 })
9987 .await?;
9988 self.state.intents_sent += 1;
9989 Ok(())
9990 }
9991
9992 pub async fn use_grant(
9994 &mut self,
9995 grant_instance_id: uuid::Uuid,
9996 target_instance_id: uuid::Uuid,
9997 ) -> anyhow::Result<()> {
9998 if !self.state.is_alive() {
9999 anyhow::bail!("you are dead");
10000 }
10001 self.seq += 1;
10002 self.session
10003 .submit_intent(Intent::UseGrant {
10004 entity_id: self.state.entity_id,
10005 grant_instance_id,
10006 target_instance_id,
10007 seq: self.seq,
10008 })
10009 .await?;
10010 self.state.intents_sent += 1;
10011 Ok(())
10012 }
10013
10014 pub fn open_craft_menu(&mut self) {
10015 self.state.show_craft_menu = true;
10016 self.state.show_shop_menu = false;
10017 self.state.shop_catalog = None;
10018 self.state.show_stats = false;
10019 self.state.show_inventory_menu = false;
10020 self.state.reload_craft_prefs();
10021 self.state.craft_tab = CraftTab::Ready;
10022 self.state.craft_filter.clear();
10023 self.state.craft_filter_focused = false;
10024 self.state.craft_menu_index = 0;
10025 self.state.clamp_craft_menu_index();
10026 self.state.craft_batch_quantity = 1;
10027 self.state.clamp_craft_batch_quantity();
10028 }
10029
10030 pub fn close_craft_menu(&mut self) {
10031 self.state.show_craft_menu = false;
10032 self.state.craft_filter_focused = false;
10033 }
10034
10035 pub fn toggle_keychain_menu(&mut self) {
10036 if self.state.show_keychain_menu {
10037 self.close_keychain_menu();
10038 } else {
10039 self.state.show_keychain_menu = true;
10040 self.state.show_craft_menu = false;
10041 self.state.show_shop_menu = false;
10042 self.state.show_inventory_menu = false;
10043 let n = self.state.keychain_entries().len();
10044 if n == 0 {
10045 self.state.keychain_menu_index = 0;
10046 } else {
10047 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
10048 }
10049 }
10050 }
10051
10052 pub fn close_keychain_menu(&mut self) {
10053 self.state.show_keychain_menu = false;
10054 }
10055
10056 pub fn keychain_menu_move(&mut self, delta: i32) {
10057 let n = self.state.keychain_entries().len();
10058 if n == 0 {
10059 self.state.keychain_menu_index = 0;
10060 return;
10061 }
10062 let idx = self.state.keychain_menu_index as i32 + delta;
10063 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
10064 }
10065
10066 pub fn keychain_menu_page(&mut self, pages: i32) {
10067 let n = self.state.keychain_entries().len();
10068 self.state.keychain_menu_index = page_list_index(self.state.keychain_menu_index, pages, n);
10069 }
10070
10071 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
10072 if !self.state.is_alive() {
10073 anyhow::bail!("you are dead");
10074 }
10075 let entries = self.state.keychain_entries();
10076 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
10077 anyhow::bail!("nothing selected");
10078 };
10079 let Some(instance_id) = entry.stack.item_instance_id else {
10080 anyhow::bail!("key has no instance id");
10081 };
10082 if entry.stowed {
10083 self.move_item(
10084 instance_id,
10085 flatland_protocol::InventoryLocation::Keychain,
10086 flatland_protocol::InventoryLocation::Root,
10087 None,
10088 Some(1),
10089 )
10090 .await
10091 } else {
10092 self.move_item(
10093 instance_id,
10094 flatland_protocol::InventoryLocation::Root,
10095 flatland_protocol::InventoryLocation::Keychain,
10096 None,
10097 Some(1),
10098 )
10099 .await
10100 }
10101 }
10102
10103 pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
10104 let npc_id = self.state.shop_catalog.as_ref().map(|c| c.npc_id.clone());
10105 self.state.show_shop_menu = false;
10106 self.state.shop_catalog = None;
10107 self.state.clear_shop_trade_log();
10108 if let Some(npc_id) = npc_id {
10109 self.seq += 1;
10110 self.session
10111 .submit_intent(Intent::ShopClose {
10112 entity_id: self.state.entity_id,
10113 npc_id,
10114 seq: self.seq,
10115 })
10116 .await?;
10117 self.state.intents_sent += 1;
10118 }
10119 Ok(())
10120 }
10121
10122 pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10123 let Some(panel) = self.state.bank_panel.clone() else {
10124 return Ok(());
10125 };
10126 self.seq += 1;
10127 self.session
10128 .submit_intent(Intent::BankDeposit {
10129 entity_id: self.state.entity_id,
10130 npc_id: panel.npc_id,
10131 amount_copper,
10132 seq: self.seq,
10133 })
10134 .await?;
10135 self.state.intents_sent += 1;
10136 Ok(())
10137 }
10138
10139 pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10140 let Some(panel) = self.state.bank_panel.clone() else {
10141 return Ok(());
10142 };
10143 self.seq += 1;
10144 self.session
10145 .submit_intent(Intent::BankWithdraw {
10146 entity_id: self.state.entity_id,
10147 npc_id: panel.npc_id,
10148 amount_copper,
10149 seq: self.seq,
10150 })
10151 .await?;
10152 self.state.intents_sent += 1;
10153 Ok(())
10154 }
10155
10156 pub async fn bank_transfer(
10157 &mut self,
10158 to_character_id: Option<uuid::Uuid>,
10159 to_name: String,
10160 amount_copper: u64,
10161 ) -> anyhow::Result<()> {
10162 let Some(panel) = self.state.bank_panel.clone() else {
10163 return Ok(());
10164 };
10165 self.seq += 1;
10166 self.session
10167 .submit_intent(Intent::BankTransfer {
10168 entity_id: self.state.entity_id,
10169 npc_id: panel.npc_id,
10170 to_character_id,
10171 to_name,
10172 amount_copper,
10173 seq: self.seq,
10174 })
10175 .await?;
10176 self.state.intents_sent += 1;
10177 Ok(())
10178 }
10179
10180 pub fn bank_menu_move(&mut self, delta: i32) {
10181 let n = self.state.bank_menu_options().len();
10182 if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
10183 return;
10184 }
10185 let idx = self.state.bank_menu_index as i32;
10186 self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10187 }
10188
10189 pub fn storage_menu_move(&mut self, delta: i32) {
10190 let n = self.state.storage_menu_options().len();
10191 if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
10192 return;
10193 }
10194 let idx = self.state.storage_menu_index as i32;
10195 self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10196 }
10197
10198 pub fn storage_pick_move(&mut self, delta: i32) {
10199 let n = match &self.state.storage_ui_mode {
10200 StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
10201 StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
10202 self.state.storage_vault_options().len()
10203 }
10204 StorageUiMode::Menu
10205 | StorageUiMode::StoreAmount { .. }
10206 | StorageUiMode::TakeAmount { .. }
10207 | StorageUiMode::ShipAmount { .. } => 0,
10208 };
10209 if n == 0 {
10210 return;
10211 }
10212 match &mut self.state.storage_ui_mode {
10213 StorageUiMode::StorePick { index }
10214 | StorageUiMode::TakePick { index }
10215 | StorageUiMode::ShipPick { index, .. } => {
10216 *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10217 }
10218 StorageUiMode::Menu
10219 | StorageUiMode::StoreAmount { .. }
10220 | StorageUiMode::TakeAmount { .. }
10221 | StorageUiMode::ShipAmount { .. } => {}
10222 }
10223 }
10224
10225 pub fn storage_ui_back(&mut self) {
10226 self.state.storage_ui_mode = match &self.state.storage_ui_mode {
10227 StorageUiMode::StoreAmount { pick_index, .. } => {
10228 StorageUiMode::StorePick { index: *pick_index }
10229 }
10230 StorageUiMode::TakeAmount { pick_index, .. } => {
10231 StorageUiMode::TakePick { index: *pick_index }
10232 }
10233 StorageUiMode::ShipAmount {
10234 dest_building_id,
10235 dest_label,
10236 pick_index,
10237 ..
10238 } => StorageUiMode::ShipPick {
10239 dest_building_id: dest_building_id.clone(),
10240 dest_label: dest_label.clone(),
10241 index: *pick_index,
10242 },
10243 StorageUiMode::StorePick { .. }
10244 | StorageUiMode::TakePick { .. }
10245 | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
10246 StorageUiMode::Menu => StorageUiMode::Menu,
10247 };
10248 }
10249
10250 pub fn storage_amount_append_char(&mut self, c: char) {
10251 match &mut self.state.storage_ui_mode {
10252 StorageUiMode::StoreAmount { input, .. }
10253 | StorageUiMode::TakeAmount { input, .. }
10254 | StorageUiMode::ShipAmount { input, .. } => {
10255 if c.is_ascii_digit() && input.len() < 8 {
10256 input.push(c);
10257 }
10258 }
10259 _ => {}
10260 }
10261 }
10262
10263 pub fn storage_amount_backspace(&mut self) {
10264 match &mut self.state.storage_ui_mode {
10265 StorageUiMode::StoreAmount { input, .. }
10266 | StorageUiMode::TakeAmount { input, .. }
10267 | StorageUiMode::ShipAmount { input, .. } => {
10268 input.pop();
10269 }
10270 _ => {}
10271 }
10272 }
10273
10274 pub fn storage_ui_typing(&self) -> bool {
10275 matches!(
10276 self.state.storage_ui_mode,
10277 StorageUiMode::StoreAmount { .. }
10278 | StorageUiMode::TakeAmount { .. }
10279 | StorageUiMode::ShipAmount { .. }
10280 )
10281 }
10282
10283 pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
10284 match self.state.storage_ui_mode.clone() {
10285 StorageUiMode::Menu => {
10286 let index = self.state.storage_menu_index;
10287 match index {
10288 0 => {
10289 let opts = self.state.storage_store_options();
10290 if opts.is_empty() {
10291 self.state.push_log("Nothing loose to store.");
10292 return Ok(());
10293 }
10294 self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
10295 }
10296 1 => {
10297 let opts = self.state.storage_vault_options();
10298 if opts.is_empty() {
10299 self.state.push_log("Vault is empty.");
10300 return Ok(());
10301 }
10302 self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
10303 }
10304 n => {
10305 let dest = self
10306 .state
10307 .storage_panel
10308 .as_ref()
10309 .and_then(|p| p.ship_destinations.get(n - 2))
10310 .cloned();
10311 let Some(dest) = dest else {
10312 return Ok(());
10313 };
10314 let opts = self.state.storage_vault_options();
10315 if opts.is_empty() {
10316 self.state.push_log("Vault is empty — nothing to ship.");
10317 return Ok(());
10318 }
10319 self.state.storage_ui_mode = StorageUiMode::ShipPick {
10320 dest_building_id: dest.building_id,
10321 dest_label: dest.label,
10322 index: 0,
10323 };
10324 }
10325 }
10326 }
10327 StorageUiMode::StorePick { index } => {
10328 let opts = self.state.storage_store_options();
10329 let Some(opt) = opts.get(index) else {
10330 self.state.push_log("Nothing loose to store.");
10331 self.state.storage_ui_mode = StorageUiMode::Menu;
10332 return Ok(());
10333 };
10334 self.state.storage_ui_mode = StorageUiMode::StoreAmount {
10335 pick_index: index,
10336 item_instance_id: opt.item_instance_id,
10337 label: opt.label.clone(),
10338 max_qty: opt.quantity.max(1),
10339 input: String::new(),
10340 };
10341 }
10342 StorageUiMode::TakePick { index } => {
10343 let opts = self.state.storage_vault_options();
10344 let Some(opt) = opts.get(index) else {
10345 self.state.push_log("Vault is empty.");
10346 self.state.storage_ui_mode = StorageUiMode::Menu;
10347 return Ok(());
10348 };
10349 self.state.storage_ui_mode = StorageUiMode::TakeAmount {
10350 pick_index: index,
10351 item_instance_id: opt.item_instance_id,
10352 label: opt.label.clone(),
10353 max_qty: opt.quantity.max(1),
10354 input: String::new(),
10355 };
10356 }
10357 StorageUiMode::ShipPick {
10358 dest_building_id,
10359 dest_label,
10360 index,
10361 } => {
10362 let opts = self.state.storage_vault_options();
10363 let Some(opt) = opts.get(index) else {
10364 self.state.push_log("Vault is empty — nothing to ship.");
10365 self.state.storage_ui_mode = StorageUiMode::Menu;
10366 return Ok(());
10367 };
10368 self.state.storage_ui_mode = StorageUiMode::ShipAmount {
10369 dest_building_id,
10370 dest_label,
10371 pick_index: index,
10372 item_instance_id: opt.item_instance_id,
10373 label: opt.label.clone(),
10374 max_qty: opt.quantity.max(1),
10375 input: String::new(),
10376 };
10377 }
10378 StorageUiMode::StoreAmount {
10379 item_instance_id,
10380 max_qty,
10381 input,
10382 ..
10383 } => {
10384 let Some(qty) = parse_storage_quantity(&input) else {
10385 self.state.push_log("Enter a quantity (blank or 0 = all).");
10386 return Ok(());
10387 };
10388 let qty = qty.map(|n| n.min(max_qty).max(1));
10389 self.storage_store(item_instance_id, qty).await?;
10390 self.state.storage_ui_mode = StorageUiMode::Menu;
10391 }
10392 StorageUiMode::TakeAmount {
10393 item_instance_id,
10394 max_qty,
10395 input,
10396 ..
10397 } => {
10398 let Some(qty) = parse_storage_quantity(&input) else {
10399 self.state.push_log("Enter a quantity (blank or 0 = all).");
10400 return Ok(());
10401 };
10402 let qty = qty.map(|n| n.min(max_qty).max(1));
10403 self.storage_take(item_instance_id, qty).await?;
10404 self.state.storage_ui_mode = StorageUiMode::Menu;
10405 }
10406 StorageUiMode::ShipAmount {
10407 dest_building_id,
10408 item_instance_id,
10409 max_qty,
10410 input,
10411 ..
10412 } => {
10413 let Some(qty) = parse_storage_quantity(&input) else {
10414 self.state.push_log("Enter a quantity (blank or 0 = all).");
10415 return Ok(());
10416 };
10417 let qty = qty.map(|n| n.min(max_qty).max(1));
10418 self.storage_ship(dest_building_id, item_instance_id, qty)
10419 .await?;
10420 self.state.storage_ui_mode = StorageUiMode::Menu;
10421 }
10422 }
10423 Ok(())
10424 }
10425
10426 pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
10427 match self.state.bank_ui_mode.clone() {
10428 BankUiMode::Menu => {
10429 let choice = self
10430 .state
10431 .bank_menu_options()
10432 .get(self.state.bank_menu_index)
10433 .copied()
10434 .unwrap_or("Deposit…");
10435 match choice {
10436 "Withdraw…" => {
10437 self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
10438 input: String::new(),
10439 };
10440 }
10441 "Deposit all" => self.bank_deposit(0).await?,
10442 "Withdraw all" => self.bank_withdraw(0).await?,
10443 "Transfer…" => {
10444 self.state.bank_ui_mode = BankUiMode::TransferName {
10445 input: String::new(),
10446 };
10447 }
10448 _ => {
10449 self.state.bank_ui_mode = BankUiMode::DepositAmount {
10450 input: String::new(),
10451 };
10452 }
10453 }
10454 }
10455 BankUiMode::DepositAmount { input } => {
10456 let Some(amount) = parse_bank_copper_amount(&input) else {
10457 self.state
10458 .push_log("Enter a copper amount (blank or 0 = everything on person).");
10459 return Ok(());
10460 };
10461 self.bank_deposit(amount).await?;
10462 self.state.bank_ui_mode = BankUiMode::Menu;
10463 }
10464 BankUiMode::WithdrawAmount { input } => {
10465 let Some(amount) = parse_bank_copper_amount(&input) else {
10466 self.state
10467 .push_log("Enter a copper amount (blank or 0 = full ledger).");
10468 return Ok(());
10469 };
10470 self.bank_withdraw(amount).await?;
10471 self.state.bank_ui_mode = BankUiMode::Menu;
10472 }
10473 BankUiMode::TransferName { input } => {
10474 let name = input.trim().to_string();
10475 if name.is_empty() {
10476 self.state.push_log("Enter the recipient character name.");
10477 return Ok(());
10478 }
10479 self.state.bank_ui_mode = BankUiMode::TransferAmount {
10480 to_name: name,
10481 input: String::new(),
10482 };
10483 }
10484 BankUiMode::TransferAmount { to_name, input } => {
10485 let amount: u64 = match input.trim().parse() {
10486 Ok(v) if v > 0 => v,
10487 _ => {
10488 self.state
10489 .push_log("Enter a positive copper amount to transfer.");
10490 return Ok(());
10491 }
10492 };
10493 self.bank_transfer(None, to_name, amount).await?;
10494 self.state.bank_ui_mode = BankUiMode::Menu;
10495 }
10496 }
10497 Ok(())
10498 }
10499
10500 pub fn bank_transfer_back(&mut self) {
10501 match &self.state.bank_ui_mode {
10502 BankUiMode::TransferAmount { to_name, .. } => {
10503 self.state.bank_ui_mode = BankUiMode::TransferName {
10504 input: to_name.clone(),
10505 };
10506 }
10507 BankUiMode::TransferName { .. }
10508 | BankUiMode::DepositAmount { .. }
10509 | BankUiMode::WithdrawAmount { .. } => {
10510 self.state.bank_ui_mode = BankUiMode::Menu;
10511 }
10512 BankUiMode::Menu => {}
10513 }
10514 }
10515
10516 pub fn bank_transfer_append_char(&mut self, c: char) {
10517 match &mut self.state.bank_ui_mode {
10518 BankUiMode::TransferName { input } => {
10519 if input.len() < 32 && !c.is_control() {
10520 input.push(c);
10521 }
10522 }
10523 BankUiMode::DepositAmount { input }
10524 | BankUiMode::WithdrawAmount { input }
10525 | BankUiMode::TransferAmount { input, .. } => {
10526 if c.is_ascii_digit() && input.len() < 12 {
10527 input.push(c);
10528 }
10529 }
10530 BankUiMode::Menu => {}
10531 }
10532 }
10533
10534 pub fn bank_transfer_backspace(&mut self) {
10535 match &mut self.state.bank_ui_mode {
10536 BankUiMode::TransferName { input }
10537 | BankUiMode::DepositAmount { input }
10538 | BankUiMode::WithdrawAmount { input }
10539 | BankUiMode::TransferAmount { input, .. } => {
10540 input.pop();
10541 }
10542 BankUiMode::Menu => {}
10543 }
10544 }
10545
10546 pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
10547 let npc_id = self.state.bank_panel.as_ref().map(|p| p.npc_id.clone());
10548 self.state.clear_bank_panel();
10549 if let Some(npc_id) = npc_id {
10550 self.seq += 1;
10551 self.session
10552 .submit_intent(Intent::BankClose {
10553 entity_id: self.state.entity_id,
10554 npc_id,
10555 seq: self.seq,
10556 })
10557 .await?;
10558 self.state.intents_sent += 1;
10559 }
10560 Ok(())
10561 }
10562
10563 pub async fn storage_store(
10564 &mut self,
10565 item_instance_id: uuid::Uuid,
10566 quantity: Option<u32>,
10567 ) -> anyhow::Result<()> {
10568 let Some(panel) = self.state.storage_panel.clone() else {
10569 return Ok(());
10570 };
10571 self.seq += 1;
10572 self.session
10573 .submit_intent(Intent::StorageStore {
10574 entity_id: self.state.entity_id,
10575 npc_id: panel.npc_id,
10576 item_instance_id,
10577 quantity,
10578 seq: self.seq,
10579 })
10580 .await?;
10581 self.state.intents_sent += 1;
10582 Ok(())
10583 }
10584
10585 pub async fn storage_take(
10586 &mut self,
10587 item_instance_id: uuid::Uuid,
10588 quantity: Option<u32>,
10589 ) -> anyhow::Result<()> {
10590 let Some(panel) = self.state.storage_panel.clone() else {
10591 return Ok(());
10592 };
10593 self.seq += 1;
10594 self.session
10595 .submit_intent(Intent::StorageTake {
10596 entity_id: self.state.entity_id,
10597 npc_id: panel.npc_id,
10598 item_instance_id,
10599 quantity,
10600 seq: self.seq,
10601 })
10602 .await?;
10603 self.state.intents_sent += 1;
10604 Ok(())
10605 }
10606
10607 pub async fn storage_ship(
10608 &mut self,
10609 dest_building_id: String,
10610 item_instance_id: uuid::Uuid,
10611 quantity: Option<u32>,
10612 ) -> anyhow::Result<()> {
10613 let Some(panel) = self.state.storage_panel.clone() else {
10614 return Ok(());
10615 };
10616 self.seq += 1;
10617 self.session
10618 .submit_intent(Intent::StorageShip {
10619 entity_id: self.state.entity_id,
10620 npc_id: panel.npc_id,
10621 dest_building_id,
10622 item_instance_id,
10623 quantity,
10624 seq: self.seq,
10625 })
10626 .await?;
10627 self.state.intents_sent += 1;
10628 Ok(())
10629 }
10630
10631 pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
10632 let npc_id = self.state.storage_panel.as_ref().map(|p| p.npc_id.clone());
10633 self.state.clear_storage_panel();
10634 if let Some(npc_id) = npc_id {
10635 self.seq += 1;
10636 self.session
10637 .submit_intent(Intent::StorageClose {
10638 entity_id: self.state.entity_id,
10639 npc_id,
10640 seq: self.seq,
10641 })
10642 .await?;
10643 self.state.intents_sent += 1;
10644 }
10645 Ok(())
10646 }
10647
10648 pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
10649 let npc_id = self.state.market_panel.as_ref().map(|p| p.npc_id.clone());
10650 self.state.clear_market_panel();
10651 if let Some(npc_id) = npc_id {
10652 self.seq += 1;
10653 self.session
10654 .submit_intent(Intent::MarketClose {
10655 entity_id: self.state.entity_id,
10656 npc_id,
10657 seq: self.seq,
10658 })
10659 .await?;
10660 self.state.intents_sent += 1;
10661 }
10662 Ok(())
10663 }
10664
10665 pub fn market_move_selection(&mut self, delta: i32) {
10666 let indices = self.state.market_filtered_listing_indices();
10667 let n = indices.len();
10668 if n == 0 {
10669 self.state.market_menu_index = 0;
10670 return;
10671 }
10672 let cur = self.state.market_menu_index as i32;
10673 self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
10674 }
10675
10676 pub fn market_page_selection(&mut self, pages: i32) {
10677 let indices = self.state.market_filtered_listing_indices();
10678 let n = indices.len();
10679 if n == 0 {
10680 self.state.market_menu_index = 0;
10681 return;
10682 }
10683 self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
10684 }
10685
10686 pub fn market_list_page(&mut self, pages: i32) {
10687 match &self.state.market_ui_mode {
10688 MarketUiMode::ListSource { index } => {
10689 let n = self.state.market_list_source_options().len();
10690 if n == 0 {
10691 return;
10692 }
10693 let next = page_list_index(*index, pages, n);
10694 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
10695 }
10696 MarketUiMode::ListPricingMode { index, .. } => {
10697 let next = page_list_index(*index, pages, 2);
10698 if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
10699 {
10700 *index = next;
10701 }
10702 }
10703 MarketUiMode::ListPick { source, index } => {
10704 let opts = self.state.market_list_item_options(source);
10705 let n = opts.len();
10706 if n == 0 {
10707 return;
10708 }
10709 let next = page_list_index(*index, pages, n);
10710 self.state.market_ui_mode = MarketUiMode::ListPick {
10711 source: source.clone(),
10712 index: next,
10713 };
10714 }
10715 _ => {}
10716 }
10717 }
10718
10719 pub fn market_cycle_category(&mut self, delta: i32) {
10720 let groups = self.state.market_available_category_groups();
10721 let mut labels: Vec<Option<&'static str>> = vec![None];
10723 labels.extend(groups.into_iter().map(Some));
10724 let n = labels.len() as i32;
10725 let cur = labels
10726 .iter()
10727 .position(|g| *g == self.state.market_category_filter)
10728 .unwrap_or(0) as i32;
10729 let next = (cur + delta).rem_euclid(n) as usize;
10730 self.state.market_category_filter = labels[next];
10731 self.state.market_menu_index = 0;
10732 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10733 let source = source.clone();
10734 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10735 }
10736 }
10737
10738 pub fn focus_market_filter(&mut self) {
10739 self.state.market_filter_focused = true;
10740 }
10741
10742 pub fn append_market_filter_char(&mut self, ch: char) {
10743 if !self.state.market_filter_focused {
10744 return;
10745 }
10746 if !is_list_filter_char(ch) {
10747 return;
10748 }
10749 if self.state.market_filter.len() < 48 {
10750 self.state.market_filter.push(ch);
10751 self.state.market_menu_index = 0;
10752 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10753 let source = source.clone();
10754 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10755 }
10756 }
10757 }
10758
10759 pub fn market_filter_backspace(&mut self) {
10760 if !self.state.market_filter_focused {
10761 return;
10762 }
10763 self.state.market_filter.pop();
10764 self.state.market_menu_index = 0;
10765 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10766 let source = source.clone();
10767 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10768 }
10769 }
10770
10771 pub fn clear_or_blur_market_filter(&mut self) -> bool {
10773 if self.state.market_filter_focused {
10774 if !self.state.market_filter.is_empty() {
10775 self.state.market_filter.clear();
10776 self.state.market_menu_index = 0;
10777 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10778 let source = source.clone();
10779 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10780 }
10781 return true;
10782 }
10783 self.state.market_filter_focused = false;
10784 return true;
10785 }
10786 if !self.state.market_filter.is_empty() {
10787 self.state.market_filter.clear();
10788 self.state.market_menu_index = 0;
10789 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10790 let source = source.clone();
10791 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10792 }
10793 return true;
10794 }
10795 false
10796 }
10797
10798 pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
10799 if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
10800 return self.market_confirm_buy(listing_id, qty).await;
10801 }
10802 let Some(panel) = self.state.market_panel.clone() else {
10803 return Ok(());
10804 };
10805 let indices = self.state.market_filtered_listing_indices();
10806 let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
10807 return Ok(());
10808 };
10809 let Some(listing) = panel.listings.get(raw_idx) else {
10810 return Ok(());
10811 };
10812 if listing.mine {
10813 self.seq += 1;
10814 self.session
10815 .submit_intent(Intent::MarketDelist {
10816 entity_id: self.state.entity_id,
10817 npc_id: panel.npc_id.clone(),
10818 listing_id: listing.listing_id,
10819 dest: flatland_protocol::GoodsLocation::Person,
10820 seq: self.seq,
10821 })
10822 .await?;
10823 self.state.intents_sent += 1;
10824 return Ok(());
10825 }
10826 if listing.npc_price {
10827 self.state
10828 .push_log("NPC-price listings are bought by merchants only.");
10829 return Ok(());
10830 }
10831 let qty = 1u32.min(listing.quantity).max(1);
10832 let line = listing.unit_price_copper.saturating_mul(qty as u64);
10833 self.state.market_buy_confirm = Some((
10834 listing.listing_id,
10835 qty,
10836 listing.unit_price_copper,
10837 line,
10838 listing.display_name.clone(),
10839 ));
10840 Ok(())
10841 }
10842
10843 pub async fn market_confirm_buy(
10844 &mut self,
10845 listing_id: uuid::Uuid,
10846 quantity: u32,
10847 ) -> anyhow::Result<()> {
10848 let Some(panel) = self.state.market_panel.clone() else {
10849 self.state.market_buy_confirm = None;
10850 return Ok(());
10851 };
10852 self.state.market_buy_confirm = None;
10853 self.seq += 1;
10854 self.session
10855 .submit_intent(Intent::MarketBuy {
10856 entity_id: self.state.entity_id,
10857 npc_id: panel.npc_id,
10858 listing_id,
10859 quantity,
10860 dest: flatland_protocol::GoodsLocation::Person,
10861 seq: self.seq,
10862 })
10863 .await?;
10864 self.state.intents_sent += 1;
10865 Ok(())
10866 }
10867
10868 pub fn market_begin_list(&mut self) {
10870 if self.state.market_panel.is_none() {
10871 return;
10872 }
10873 let sources = self.state.market_list_source_options();
10874 if sources.is_empty() {
10875 self.state.push_log("Nothing to list from.");
10876 return;
10877 }
10878 if sources.len() == 1 {
10880 let (source, _) = sources[0].clone();
10881 let opts = self.state.market_list_item_options(&source);
10882 if opts.is_empty() {
10883 self.state.push_log("Nothing loose to list.");
10884 return;
10885 }
10886 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10887 self.state.market_buy_confirm = None;
10888 return;
10889 }
10890 self.state.market_buy_confirm = None;
10891 self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
10892 }
10893
10894 pub fn market_ui_back(&mut self) {
10895 self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
10896 MarketUiMode::Browse => MarketUiMode::Browse,
10897 MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
10898 MarketUiMode::ListPick { .. } => {
10899 if self.state.market_list_source_options().len() <= 1 {
10900 MarketUiMode::Browse
10901 } else {
10902 MarketUiMode::ListSource { index: 0 }
10903 }
10904 }
10905 MarketUiMode::ListAmount {
10906 source, pick_index, ..
10907 } => MarketUiMode::ListPick {
10908 source,
10909 index: pick_index,
10910 },
10911 MarketUiMode::ListPricingMode {
10912 source,
10913 item_instance_id,
10914 template_id,
10915 label,
10916 max_qty,
10917 quantity,
10918 pick_index,
10919 ..
10920 } => {
10921 let input = quantity.map(|q| q.to_string()).unwrap_or_default();
10922 MarketUiMode::ListAmount {
10923 source,
10924 pick_index,
10925 item_instance_id,
10926 template_id,
10927 label,
10928 max_qty,
10929 input,
10930 }
10931 }
10932 MarketUiMode::ListPrice {
10933 source,
10934 pick_index,
10935 item_instance_id,
10936 template_id,
10937 label,
10938 max_qty,
10939 quantity,
10940 ..
10941 } => MarketUiMode::ListPricingMode {
10942 source,
10943 pick_index,
10944 item_instance_id,
10945 template_id,
10946 label,
10947 quantity,
10948 max_qty,
10949 index: 1,
10950 },
10951 };
10952 }
10953
10954 pub fn market_list_move(&mut self, delta: i32) {
10955 match &self.state.market_ui_mode {
10956 MarketUiMode::ListSource { index } => {
10957 let n = self.state.market_list_source_options().len();
10958 if n == 0 {
10959 return;
10960 }
10961 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10962 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
10963 }
10964 MarketUiMode::ListPricingMode { index, .. } => {
10965 let next = (*index as i32 + delta).rem_euclid(2) as usize;
10966 if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
10967 {
10968 *index = next;
10969 }
10970 }
10971 MarketUiMode::ListPick { source, index } => {
10972 let opts = self.state.market_list_item_options(source);
10973 let n = opts.len();
10974 if n == 0 {
10975 return;
10976 }
10977 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10978 self.state.market_ui_mode = MarketUiMode::ListPick {
10979 source: source.clone(),
10980 index: next,
10981 };
10982 }
10983 _ => {}
10984 }
10985 }
10986
10987 pub fn market_list_amount_append_char(&mut self, c: char) {
10988 if !c.is_ascii_digit() {
10989 return;
10990 }
10991 match &mut self.state.market_ui_mode {
10992 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
10993 if input.len() < 12 {
10994 input.push(c);
10995 }
10996 }
10997 _ => {}
10998 }
10999 }
11000
11001 pub fn market_list_amount_backspace(&mut self) {
11002 match &mut self.state.market_ui_mode {
11003 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
11004 input.pop();
11005 }
11006 _ => {}
11007 }
11008 }
11009
11010 pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
11011 match self.state.market_ui_mode.clone() {
11012 MarketUiMode::Browse => Ok(()),
11013 MarketUiMode::ListSource { index } => {
11014 let sources = self.state.market_list_source_options();
11015 let Some((source, _)) = sources.get(index).cloned() else {
11016 return Ok(());
11017 };
11018 let opts = self.state.market_list_item_options(&source);
11019 if opts.is_empty() {
11020 self.state.push_log("Nothing to list from that source.");
11021 return Ok(());
11022 }
11023 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11024 Ok(())
11025 }
11026 MarketUiMode::ListPick { source, index } => {
11027 let opts = self.state.market_list_item_options(&source);
11028 let Some(opt) = opts.get(index) else {
11029 self.state.push_log("Nothing to list.");
11030 self.state.market_ui_mode = MarketUiMode::Browse;
11031 return Ok(());
11032 };
11033 self.state.market_ui_mode = MarketUiMode::ListAmount {
11034 source,
11035 pick_index: index,
11036 item_instance_id: opt.item_instance_id,
11037 template_id: opt.template_id.clone(),
11038 label: opt.label.clone(),
11039 max_qty: opt.quantity.max(1),
11040 input: String::new(),
11041 };
11042 Ok(())
11043 }
11044 MarketUiMode::ListAmount {
11045 source,
11046 pick_index,
11047 item_instance_id,
11048 template_id,
11049 label,
11050 max_qty,
11051 input,
11052 ..
11053 } => {
11054 let Some(qty_opt) = parse_storage_quantity(&input) else {
11055 self.state.push_log("Enter a quantity (blank = all).");
11056 return Ok(());
11057 };
11058 if let Some(q) = qty_opt {
11059 if q > max_qty {
11060 self.state.push_log(format!("Only {max_qty} available."));
11061 return Ok(());
11062 }
11063 }
11064 self.state.market_ui_mode = MarketUiMode::ListPricingMode {
11065 source,
11066 pick_index,
11067 item_instance_id,
11068 template_id,
11069 label,
11070 quantity: qty_opt,
11071 max_qty,
11072 index: 0,
11073 };
11074 Ok(())
11075 }
11076 MarketUiMode::ListPricingMode {
11077 source,
11078 pick_index,
11079 item_instance_id,
11080 template_id,
11081 label,
11082 quantity,
11083 max_qty,
11084 index,
11085 } => {
11086 if index == 0 {
11087 if self
11088 .state
11089 .npc_market_dump_unit_estimate(&template_id)
11090 .is_none()
11091 {
11092 self.state
11093 .push_log("That item has no NPC value — use a fixed price instead.");
11094 return Ok(());
11095 }
11096 return self
11097 .submit_market_list_intent(
11098 source,
11099 item_instance_id,
11100 quantity,
11101 0,
11102 true,
11103 &label,
11104 )
11105 .await;
11106 }
11107 self.state.market_ui_mode = MarketUiMode::ListPrice {
11108 source,
11109 pick_index,
11110 item_instance_id,
11111 template_id,
11112 label,
11113 quantity,
11114 max_qty,
11115 input: String::new(),
11116 };
11117 Ok(())
11118 }
11119 MarketUiMode::ListPrice {
11120 source,
11121 item_instance_id,
11122 label,
11123 quantity,
11124 input,
11125 ..
11126 } => {
11127 let price = input.trim().parse::<u64>().unwrap_or(0);
11128 if price == 0 {
11129 self.state
11130 .push_log("Enter a unit price of at least 1 copper.");
11131 return Ok(());
11132 }
11133 self.submit_market_list_intent(
11134 source,
11135 item_instance_id,
11136 quantity,
11137 price,
11138 false,
11139 &label,
11140 )
11141 .await
11142 }
11143 }
11144 }
11145
11146 async fn submit_market_list_intent(
11147 &mut self,
11148 source: MarketListSourceKind,
11149 item_instance_id: uuid::Uuid,
11150 quantity: Option<u32>,
11151 unit_price_copper: u64,
11152 npc_price: bool,
11153 label: &str,
11154 ) -> anyhow::Result<()> {
11155 let Some(panel) = self.state.market_panel.clone() else {
11156 self.state.market_ui_mode = MarketUiMode::Browse;
11157 return Ok(());
11158 };
11159 let goods = match source {
11160 MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
11161 MarketListSourceKind::TownStorage { building_id } => {
11162 flatland_protocol::GoodsLocation::TownStorage { building_id }
11163 }
11164 };
11165 self.seq += 1;
11166 self.session
11167 .submit_intent(Intent::MarketList {
11168 entity_id: self.state.entity_id,
11169 npc_id: panel.npc_id,
11170 source: goods,
11171 item_instance_id,
11172 quantity,
11173 unit_price_copper,
11174 npc_price,
11175 seq: self.seq,
11176 })
11177 .await?;
11178 self.state.intents_sent += 1;
11179 if npc_price {
11180 self.state
11181 .push_log(format!("Listing {label} at NPC price…"));
11182 } else {
11183 self.state
11184 .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
11185 }
11186 self.state.market_ui_mode = MarketUiMode::Browse;
11187 Ok(())
11188 }
11189
11190 pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
11192 let return_to_verbs = self.state.npc_verb_target.is_some();
11193 self.close_shop_menu().await?;
11194 if return_to_verbs {
11195 self.state.show_npc_verb_menu = true;
11196 self.state.npc_verb_notice = None;
11197 }
11198 Ok(())
11199 }
11200
11201 pub fn shop_tab_toggle(&mut self) {
11202 self.state.shop_tab = match self.state.shop_tab {
11203 ShopTab::Buy => ShopTab::Sell,
11204 ShopTab::Sell => ShopTab::Buy,
11205 };
11206 self.state.shop_menu_index = 0;
11207 if self.state.shop_tab == ShopTab::Sell {
11208 self.state.shop_quantity_set_max();
11209 }
11210 self.state.clamp_shop_selection();
11211 }
11212
11213 pub fn shop_menu_move(&mut self, delta: i32) {
11214 self.state.shop_menu_move(delta);
11215 }
11216
11217 pub fn shop_quantity_adjust(&mut self, delta: i32) {
11218 self.state.shop_quantity_adjust(delta);
11219 }
11220
11221 pub fn shop_quantity_set_max(&mut self) {
11222 self.state.shop_quantity_set_max();
11223 }
11224
11225 pub fn shop_quantity_set_min(&mut self) {
11226 self.state.shop_quantity_set_min();
11227 }
11228
11229 pub fn toggle_quest_menu(&mut self) {
11230 self.state.show_quest_menu = !self.state.show_quest_menu;
11231 if self.state.show_quest_menu {
11232 self.state.quest_menu_index = 0;
11233 self.state.quest_withdraw_confirm = false;
11234 self.state.show_workers_menu = false;
11235 }
11236 }
11237
11238 pub fn toggle_workers_menu(&mut self) {
11239 if self.state.show_workers_menu {
11240 self.close_workers_menu_ui();
11241 } else {
11242 self.state.show_workers_menu = true;
11243 if self.state.workers_menu_index >= self.state.hired_workers.len() {
11245 self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11246 }
11247 self.state.show_quest_menu = false;
11248 self.close_worker_give_picker();
11249 self.close_worker_give_target_picker();
11250 self.close_worker_take_picker();
11251 self.close_worker_teach_picker();
11252 self.cancel_worker_rename();
11253 }
11254 }
11255
11256 pub fn close_workers_menu_ui(&mut self) {
11258 self.state.show_workers_menu = false;
11259 self.cancel_worker_dismissal();
11260 self.close_worker_give_picker();
11261 self.close_worker_give_target_picker();
11262 self.close_worker_take_picker();
11263 self.close_worker_teach_picker();
11264 self.cancel_worker_rename();
11265 }
11266
11267 pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
11269 let Some(idx) = self
11270 .state
11271 .hired_workers
11272 .iter()
11273 .position(|w| w.instance_id == instance_id)
11274 else {
11275 anyhow::bail!("worker not found");
11276 };
11277 let label = self.state.hired_workers[idx].label.clone();
11278 self.state.show_workers_menu = true;
11279 self.state.workers_menu_index = idx;
11280 self.state.show_quest_menu = false;
11281 self.close_worker_give_picker();
11282 self.close_worker_give_target_picker();
11283 self.close_worker_take_picker();
11284 self.close_worker_teach_picker();
11285 self.cancel_worker_rename();
11286 self.set_worker_attending(instance_id, true).await?;
11287 self.state
11288 .push_log(format!("Managing {label} — job paused while menu is open"));
11289 Ok(())
11290 }
11291
11292 pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
11294 self.close_workers_menu_ui();
11295 self.release_worker_attend().await
11296 }
11297
11298 async fn set_worker_attending(
11299 &mut self,
11300 instance_id: &str,
11301 attending: bool,
11302 ) -> anyhow::Result<()> {
11303 if attending {
11304 if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11305 return Ok(());
11306 }
11307 if let Some(prev) = self.state.attending_worker_instance_id.clone() {
11309 if prev != instance_id {
11310 self.send_attend_hired_worker(&prev, false).await?;
11311 }
11312 }
11313 self.send_attend_hired_worker(instance_id, true).await?;
11314 self.state.attending_worker_instance_id = Some(instance_id.to_string());
11315 } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11316 self.send_attend_hired_worker(instance_id, false).await?;
11317 self.state.attending_worker_instance_id = None;
11318 }
11319 Ok(())
11320 }
11321
11322 pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
11323 let Some(id) = self.state.attending_worker_instance_id.take() else {
11324 return Ok(());
11325 };
11326 self.send_attend_hired_worker(&id, false).await
11327 }
11328
11329 async fn send_attend_hired_worker(
11330 &mut self,
11331 worker_instance_id: &str,
11332 attending: bool,
11333 ) -> anyhow::Result<()> {
11334 self.seq += 1;
11335 self.session
11336 .submit_intent(Intent::AttendHiredWorker {
11337 entity_id: self.state.entity_id,
11338 worker_instance_id: worker_instance_id.to_string(),
11339 attending,
11340 seq: self.seq,
11341 })
11342 .await?;
11343 self.state.intents_sent += 1;
11344 Ok(())
11345 }
11346
11347 pub fn workers_menu_move(&mut self, delta: i32) {
11348 let n = self.state.hired_workers.len();
11349 if n == 0 {
11350 return;
11351 }
11352 let idx = self.state.workers_menu_index as i32;
11353 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
11354 }
11355
11356 pub fn toggle_workers_menu_compact(&mut self) {
11357 self.state.workers_menu_compact = !self.state.workers_menu_compact;
11358 let mut cfg = crate::client_config::ClientConfig::load();
11359 let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
11360 }
11361
11362 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
11363 let Some(worker) = self
11364 .state
11365 .hired_workers
11366 .get(self.state.workers_menu_index)
11367 .cloned()
11368 else {
11369 anyhow::bail!("no worker selected");
11370 };
11371 self.dismiss_worker_by_id(&worker.instance_id, &worker.label)
11372 .await
11373 }
11374
11375 pub fn request_worker_dismissal(&mut self) -> anyhow::Result<()> {
11377 let Some(worker) = self
11378 .state
11379 .hired_workers
11380 .get(self.state.workers_menu_index)
11381 .cloned()
11382 else {
11383 anyhow::bail!("no worker selected");
11384 };
11385 self.state.worker_dismiss_confirmation = Some(WorkerDismissConfirmation {
11386 worker_instance_id: worker.instance_id,
11387 worker_label: worker.label,
11388 });
11389 Ok(())
11390 }
11391
11392 pub fn cancel_worker_dismissal(&mut self) {
11393 self.state.worker_dismiss_confirmation = None;
11394 }
11395
11396 pub async fn confirm_worker_dismissal(&mut self) -> anyhow::Result<()> {
11397 let Some(confirm) = self.state.worker_dismiss_confirmation.clone() else {
11398 return Ok(());
11399 };
11400 self.dismiss_worker_by_id(&confirm.worker_instance_id, &confirm.worker_label)
11401 .await?;
11402 self.cancel_worker_dismissal();
11403 Ok(())
11404 }
11405
11406 async fn dismiss_worker_by_id(
11407 &mut self,
11408 worker_instance_id: &str,
11409 worker_label: &str,
11410 ) -> anyhow::Result<()> {
11411 self.seq += 1;
11412 self.session
11413 .submit_intent(Intent::DismissWorker {
11414 entity_id: self.state.entity_id,
11415 worker_instance_id: worker_instance_id.to_string(),
11416 seq: self.seq,
11417 })
11418 .await?;
11419 self.state.intents_sent += 1;
11420 self.state
11421 .hired_workers
11422 .retain(|w| w.instance_id != worker_instance_id);
11423 if self.state.workers_menu_index >= self.state.hired_workers.len() {
11424 self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11425 }
11426 self.state.push_log(format!("Dismissed {worker_label}"));
11427 Ok(())
11428 }
11429
11430 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
11431 let Some(worker) = self
11432 .state
11433 .hired_workers
11434 .get(self.state.workers_menu_index)
11435 .cloned()
11436 else {
11437 anyhow::bail!("no worker selected");
11438 };
11439 let mode = match worker.mode {
11440 flatland_protocol::WorkerModeView::Companion => "defender",
11441 flatland_protocol::WorkerModeView::Defender => "job_loop",
11442 flatland_protocol::WorkerModeView::JobLoop => "idle",
11443 flatland_protocol::WorkerModeView::Idle => "companion",
11444 };
11445 self.seq += 1;
11446 self.session
11447 .submit_intent(Intent::SetWorkerMode {
11448 entity_id: self.state.entity_id,
11449 worker_instance_id: worker.instance_id,
11450 mode: mode.into(),
11451 seq: self.seq,
11452 })
11453 .await?;
11454 self.state.intents_sent += 1;
11455 Ok(())
11456 }
11457
11458 pub async fn workers_deliver_selected_to_storage(&mut self) -> anyhow::Result<()> {
11459 let Some(worker) = self
11460 .state
11461 .hired_workers
11462 .get(self.state.workers_menu_index)
11463 .cloned()
11464 else {
11465 anyhow::bail!("no worker selected");
11466 };
11467 if !matches!(worker.mode, flatland_protocol::WorkerModeView::Companion) {
11468 anyhow::bail!("switch the worker to companion mode first");
11469 }
11470 if worker.step_label.starts_with("delivering to ")
11471 || worker.step_label == "returning to you"
11472 {
11473 anyhow::bail!("worker is already delivering to storage");
11474 }
11475 self.seq += 1;
11476 self.session
11477 .submit_intent(Intent::DeliverWorkerToNearestStorage {
11478 entity_id: self.state.entity_id,
11479 worker_instance_id: worker.instance_id.clone(),
11480 seq: self.seq,
11481 })
11482 .await?;
11483 self.state.intents_sent += 1;
11484 self.state.push_log(format!(
11485 "{} is delivering carried items to storage",
11486 worker.label
11487 ));
11488 Ok(())
11489 }
11490
11491 pub async fn workers_cancel_delivery_selected(&mut self) -> anyhow::Result<()> {
11492 let Some(worker) = self
11493 .state
11494 .hired_workers
11495 .get(self.state.workers_menu_index)
11496 .cloned()
11497 else {
11498 anyhow::bail!("no worker selected");
11499 };
11500 if !(worker.step_label.starts_with("delivering to ")
11501 || worker.step_label == "returning to you")
11502 {
11503 anyhow::bail!("worker has no active delivery");
11504 }
11505 self.seq += 1;
11506 self.session
11507 .submit_intent(Intent::CancelWorkerDelivery {
11508 entity_id: self.state.entity_id,
11509 worker_instance_id: worker.instance_id,
11510 seq: self.seq,
11511 })
11512 .await?;
11513 self.state.intents_sent += 1;
11514 self.state
11515 .push_log(format!("Canceled delivery for {}", worker.label));
11516 Ok(())
11517 }
11518
11519 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
11520 if self.state.hired_workers.is_empty() {
11521 return self.hire_worker_laborer().await;
11522 }
11523 self.workers_toggle_mode_selected().await
11524 }
11525
11526 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11529 let row = self
11530 .state
11531 .inventory_selected_row()
11532 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
11533 .clone();
11534 if row.from != flatland_protocol::InventoryLocation::Root {
11535 anyhow::bail!("select a carried item to give");
11536 }
11537 let Some(instance_id) = row.stack.item_instance_id else {
11538 anyhow::bail!("that stack can't be given");
11539 };
11540 let options = self.nearby_worker_give_targets();
11541 if options.is_empty() {
11542 anyhow::bail!(
11543 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
11544 );
11545 }
11546 let item_label = row
11547 .stack
11548 .display_name
11549 .as_deref()
11550 .unwrap_or(&row.stack.template_id)
11551 .to_string();
11552 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
11553 item_instance_id: instance_id,
11554 item_label,
11555 quantity: None,
11556 options,
11557 });
11558 self.state.worker_give_target_picker_index = 0;
11559 self.state.show_worker_give_target_picker = true;
11560 self.state.show_inventory_menu = false;
11562 Ok(())
11563 }
11564
11565 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
11567 let (px, py, _) = self.state.player_position_with_z();
11568 let mut options: Vec<WorkerGiveTargetOption> = self
11569 .state
11570 .hired_workers
11571 .iter()
11572 .filter_map(|w| {
11573 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
11574 if dist > WORKER_GIVE_RANGE_M {
11575 return None;
11576 }
11577 Some(WorkerGiveTargetOption {
11578 instance_id: w.instance_id.clone(),
11579 label: w.label.clone(),
11580 distance_m: dist,
11581 })
11582 })
11583 .collect();
11584 options.sort_by(|a, b| {
11585 a.distance_m
11586 .partial_cmp(&b.distance_m)
11587 .unwrap_or(std::cmp::Ordering::Equal)
11588 });
11589 options
11590 }
11591
11592 pub fn close_worker_give_target_picker(&mut self) {
11593 self.state.show_worker_give_target_picker = false;
11594 self.state.worker_give_target_picker = None;
11595 self.state.worker_give_target_picker_index = 0;
11596 }
11597
11598 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
11599 let Some(picker) = &self.state.worker_give_target_picker else {
11600 return;
11601 };
11602 let n = picker.options.len();
11603 if n == 0 {
11604 return;
11605 }
11606 let idx = self.state.worker_give_target_picker_index as i32;
11607 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11608 }
11609
11610 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11611 let Some(picker) = self.state.worker_give_target_picker.clone() else {
11612 anyhow::bail!("give target picker not open");
11613 };
11614 let Some(opt) = picker
11615 .options
11616 .get(self.state.worker_give_target_picker_index)
11617 .cloned()
11618 else {
11619 anyhow::bail!("no worker selected");
11620 };
11621 let Some(worker) = self
11622 .state
11623 .hired_workers
11624 .iter()
11625 .find(|w| w.instance_id == opt.instance_id)
11626 .cloned()
11627 else {
11628 self.close_worker_give_target_picker();
11629 anyhow::bail!("worker no longer hired");
11630 };
11631 self.give_item_to_worker(
11632 &worker.instance_id,
11633 &worker.label,
11634 worker.x,
11635 worker.y,
11636 picker.item_instance_id,
11637 &picker.item_label,
11638 picker.quantity,
11639 )
11640 .await?;
11641 self.close_worker_give_target_picker();
11642 Ok(())
11643 }
11644
11645 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
11647 self.open_worker_give_target_picker()
11648 }
11649
11650 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
11652 let Some(worker) = self
11653 .state
11654 .hired_workers
11655 .get(self.state.workers_menu_index)
11656 .cloned()
11657 else {
11658 anyhow::bail!("select a hired worker first");
11659 };
11660 let (px, py, _) = self.state.player_position_with_z();
11661 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11662 if dist > WORKER_GIVE_RANGE_M {
11663 anyhow::bail!(
11664 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
11665 worker.label
11666 );
11667 }
11668 let options = self.state.giveable_inventory_options();
11669 if options.is_empty() {
11670 anyhow::bail!("nothing in inventory to give");
11671 }
11672 self.state.worker_give_picker = Some(WorkerGivePicker {
11673 worker_instance_id: worker.instance_id,
11674 worker_label: worker.label,
11675 options,
11676 });
11677 self.state.worker_give_picker_index = 0;
11678 self.state.show_worker_give_picker = true;
11679 Ok(())
11680 }
11681
11682 pub fn close_worker_give_picker(&mut self) {
11683 self.state.show_worker_give_picker = false;
11684 self.state.worker_give_picker = None;
11685 self.state.worker_give_picker_index = 0;
11686 }
11687
11688 pub fn worker_give_picker_move(&mut self, delta: i32) {
11689 let Some(picker) = &self.state.worker_give_picker else {
11690 return;
11691 };
11692 let n = picker.options.len();
11693 if n == 0 {
11694 return;
11695 }
11696 let idx = self.state.worker_give_picker_index as i32;
11697 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11698 }
11699
11700 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
11702 let Some(picker) = self.state.worker_give_picker.clone() else {
11703 anyhow::bail!("give picker not open");
11704 };
11705 let Some(opt) = picker
11706 .options
11707 .get(self.state.worker_give_picker_index)
11708 .cloned()
11709 else {
11710 anyhow::bail!("no item selected");
11711 };
11712 let Some(worker) = self
11713 .state
11714 .hired_workers
11715 .iter()
11716 .find(|w| w.instance_id == picker.worker_instance_id)
11717 .cloned()
11718 else {
11719 self.close_worker_give_picker();
11720 anyhow::bail!("worker no longer hired");
11721 };
11722 self.give_item_to_worker(
11723 &worker.instance_id,
11724 &worker.label,
11725 worker.x,
11726 worker.y,
11727 opt.item_instance_id,
11728 &opt.label,
11729 None,
11730 )
11731 .await?;
11732 let options = self.state.giveable_inventory_options();
11734 if options.is_empty() {
11735 self.close_worker_give_picker();
11736 } else {
11737 self.state.worker_give_picker = Some(WorkerGivePicker {
11738 worker_instance_id: picker.worker_instance_id,
11739 worker_label: picker.worker_label,
11740 options,
11741 });
11742 if self.state.worker_give_picker_index
11743 >= self
11744 .state
11745 .worker_give_picker
11746 .as_ref()
11747 .map(|p| p.options.len())
11748 .unwrap_or(0)
11749 {
11750 self.state.worker_give_picker_index = self
11751 .state
11752 .worker_give_picker
11753 .as_ref()
11754 .map(|p| p.options.len().saturating_sub(1))
11755 .unwrap_or(0);
11756 }
11757 }
11758 Ok(())
11759 }
11760
11761 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11763 let Some(worker) = self
11764 .state
11765 .hired_workers
11766 .get(self.state.workers_menu_index)
11767 .cloned()
11768 else {
11769 anyhow::bail!("select a hired worker first");
11770 };
11771 let (px, py, _) = self.state.player_position_with_z();
11772 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11773 if dist > WORKER_GIVE_RANGE_M {
11774 anyhow::bail!(
11775 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
11776 worker.label
11777 );
11778 }
11779 let options = self.state.teachable_blueprint_options(&worker);
11780 if options.is_empty() {
11781 anyhow::bail!("no recipes you know that {} still needs", worker.label);
11782 }
11783 self.state.worker_teach_picker = Some(WorkerTeachPicker {
11784 worker_instance_id: worker.instance_id,
11785 worker_label: worker.label,
11786 worker_level: worker.level,
11787 options,
11788 });
11789 self.state.worker_teach_picker_index = 0;
11790 self.state.show_worker_teach_picker = true;
11791 Ok(())
11792 }
11793
11794 pub fn close_worker_teach_picker(&mut self) {
11795 self.state.show_worker_teach_picker = false;
11796 self.state.worker_teach_picker = None;
11797 self.state.worker_teach_picker_index = 0;
11798 }
11799
11800 pub fn worker_teach_picker_move(&mut self, delta: i32) {
11801 let Some(picker) = &self.state.worker_teach_picker else {
11802 return;
11803 };
11804 let n = picker.options.len();
11805 if n == 0 {
11806 return;
11807 }
11808 let idx = self.state.worker_teach_picker_index as i32;
11809 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11810 }
11811
11812 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11813 let Some(picker) = self.state.worker_teach_picker.clone() else {
11814 anyhow::bail!("teach picker not open");
11815 };
11816 let Some(opt) = picker
11817 .options
11818 .get(self.state.worker_teach_picker_index)
11819 .cloned()
11820 else {
11821 anyhow::bail!("nothing selected");
11822 };
11823 if !opt.level_ok {
11824 anyhow::bail!(
11825 "{} needs level {} (is level {})",
11826 picker.worker_label,
11827 opt.min_level,
11828 opt.worker_level
11829 );
11830 }
11831 if !opt.can_afford {
11832 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
11833 }
11834 let Some(worker) = self
11835 .state
11836 .hired_workers
11837 .iter()
11838 .find(|w| w.instance_id == picker.worker_instance_id)
11839 .cloned()
11840 else {
11841 anyhow::bail!("worker gone");
11842 };
11843 let (px, py, _) = self.state.player_position_with_z();
11844 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11845 if dist > WORKER_GIVE_RANGE_M {
11846 anyhow::bail!("worker {} too far — stand next to them", worker.label);
11847 }
11848 self.seq += 1;
11849 self.session
11850 .submit_intent(Intent::TeachWorkerBlueprint {
11851 entity_id: self.state.entity_id,
11852 worker_instance_id: picker.worker_instance_id.clone(),
11853 blueprint_id: opt.blueprint_id.clone(),
11854 seq: self.seq,
11855 })
11856 .await?;
11857 self.state.intents_sent += 1;
11858 self.state.push_log(format!(
11859 "Teaching {} to {} ({} cp)",
11860 opt.label, picker.worker_label, opt.cost_copper
11861 ));
11862 self.close_worker_teach_picker();
11863 Ok(())
11864 }
11865
11866 async fn give_item_to_worker(
11867 &mut self,
11868 worker_instance_id: &str,
11869 worker_label: &str,
11870 worker_x: f32,
11871 worker_y: f32,
11872 item_instance_id: uuid::Uuid,
11873 item_label: &str,
11874 quantity: Option<u32>,
11875 ) -> anyhow::Result<()> {
11876 let (px, py, _) = self.state.player_position_with_z();
11877 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
11878 if dist > WORKER_GIVE_RANGE_M {
11879 anyhow::bail!("worker {worker_label} too far — stand next to them");
11880 }
11881 self.seq += 1;
11882 self.session
11883 .submit_intent(Intent::GiveWorkerItem {
11884 entity_id: self.state.entity_id,
11885 worker_instance_id: worker_instance_id.to_string(),
11886 item_instance_id,
11887 quantity,
11888 seq: self.seq,
11889 })
11890 .await?;
11891 self.state.intents_sent += 1;
11892 self.state
11893 .remove_carried_instance(item_instance_id, quantity);
11894 self.state
11895 .push_log(format!("Gave {item_label} to {worker_label}"));
11896 Ok(())
11897 }
11898
11899 pub async fn equip_item_on_worker(
11903 &mut self,
11904 worker_instance_id: &str,
11905 item_instance_id: uuid::Uuid,
11906 slot: &str,
11907 ) -> anyhow::Result<()> {
11908 let Some(worker) = self
11909 .state
11910 .hired_workers
11911 .iter()
11912 .find(|worker| worker.instance_id == worker_instance_id)
11913 .cloned()
11914 else {
11915 anyhow::bail!("worker not found");
11916 };
11917 let (px, py, _) = self.state.player_position_with_z();
11918 if (worker.x - px).hypot(worker.y - py) > WORKER_GIVE_RANGE_M {
11919 anyhow::bail!("worker {} too far — stand next to them", worker.label);
11920 }
11921 self.seq += 1;
11922 self.session
11923 .submit_intent(Intent::EquipWorkerItem {
11924 entity_id: self.state.entity_id,
11925 worker_instance_id: worker.instance_id.clone(),
11926 item_instance_id,
11927 slot: slot.to_string(),
11928 seq: self.seq,
11929 })
11930 .await?;
11931 self.state.intents_sent += 1;
11932 self.state
11933 .push_log(format!("Equipped {slot} on {}", worker.label));
11934 Ok(())
11935 }
11936
11937 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
11939 let Some(worker) = self
11940 .state
11941 .hired_workers
11942 .get(self.state.workers_menu_index)
11943 .cloned()
11944 else {
11945 anyhow::bail!("select a hired worker first");
11946 };
11947 let (px, py, _) = self.state.player_position_with_z();
11948 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11949 if dist > WORKER_GIVE_RANGE_M {
11950 anyhow::bail!(
11951 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
11952 worker.label
11953 );
11954 }
11955 let options = Self::worker_inventory_options(&worker);
11956 if options.is_empty() {
11957 anyhow::bail!("{} isn't carrying anything", worker.label);
11958 }
11959 let initial_qty = options
11960 .first()
11961 .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
11962 .unwrap_or(1);
11963 self.state.worker_take_picker = Some(WorkerTakePicker {
11964 worker_instance_id: worker.instance_id,
11965 worker_label: worker.label,
11966 options,
11967 quantity: initial_qty,
11968 });
11969 self.state.worker_take_picker_index = 0;
11970 self.state.show_worker_take_picker = true;
11971 Ok(())
11972 }
11973
11974 fn worker_inventory_options(
11975 worker: &flatland_protocol::HiredWorkerView,
11976 ) -> Vec<WorkerGiveOption> {
11977 worker
11978 .inventory
11979 .iter()
11980 .filter_map(|stack| {
11981 let item_instance_id = stack.item_instance_id?;
11982 let label = stack
11983 .display_name
11984 .clone()
11985 .unwrap_or_else(|| stack.template_id.clone());
11986 let label = if stack.quantity > 1 {
11987 format!("{label} ×{}", stack.quantity)
11988 } else {
11989 label
11990 };
11991 Some(WorkerGiveOption {
11992 item_instance_id,
11993 label,
11994 quantity: stack.quantity,
11995 template_id: stack.template_id.clone(),
11996 })
11997 })
11998 .collect()
11999 }
12000
12001 pub fn close_worker_take_picker(&mut self) {
12002 self.state.show_worker_take_picker = false;
12003 self.state.worker_take_picker = None;
12004 self.state.worker_take_picker_index = 0;
12005 }
12006
12007 pub fn worker_take_picker_move(&mut self, delta: i32) {
12008 let Some(picker) = &self.state.worker_take_picker else {
12009 return;
12010 };
12011 let n = picker.options.len();
12012 if n == 0 {
12013 return;
12014 }
12015 let idx = self.state.worker_take_picker_index as i32;
12016 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
12017 self.clamp_worker_take_quantity();
12018 }
12019
12020 pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
12021 let Some(picker) = &mut self.state.worker_take_picker else {
12022 return;
12023 };
12024 let max = picker
12025 .options
12026 .get(self.state.worker_take_picker_index)
12027 .map(|o| o.quantity.max(1))
12028 .unwrap_or(1);
12029 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
12030 picker.quantity = next as u32;
12031 }
12032
12033 pub fn worker_take_picker_set_quantity_max(&mut self) {
12034 let Some(picker) = &mut self.state.worker_take_picker else {
12035 return;
12036 };
12037 let max = picker
12038 .options
12039 .get(self.state.worker_take_picker_index)
12040 .map(|o| o.quantity.max(1))
12041 .unwrap_or(1);
12042 picker.quantity = max;
12043 }
12044
12045 pub fn worker_take_picker_set_quantity_min(&mut self) {
12046 let Some(picker) = &mut self.state.worker_take_picker else {
12047 return;
12048 };
12049 picker.quantity = 1;
12050 self.clamp_worker_take_quantity();
12051 }
12052
12053 fn clamp_worker_take_quantity(&mut self) {
12054 let Some(picker) = &mut self.state.worker_take_picker else {
12055 return;
12056 };
12057 let max = picker
12058 .options
12059 .get(self.state.worker_take_picker_index)
12060 .map(|o| o.quantity.max(1))
12061 .unwrap_or(1);
12062 if picker.quantity == 0 || picker.quantity > max {
12063 picker.quantity = if max > 1 { 1 } else { max };
12064 }
12065 }
12066
12067 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
12068 let Some(picker) = self.state.worker_take_picker.clone() else {
12069 anyhow::bail!("take picker not open");
12070 };
12071 let Some(opt) = picker
12072 .options
12073 .get(self.state.worker_take_picker_index)
12074 .cloned()
12075 else {
12076 anyhow::bail!("no item selected");
12077 };
12078 let Some(worker) = self
12079 .state
12080 .hired_workers
12081 .iter()
12082 .find(|w| w.instance_id == picker.worker_instance_id)
12083 .cloned()
12084 else {
12085 self.close_worker_take_picker();
12086 anyhow::bail!("worker no longer hired");
12087 };
12088 let qty = picker.quantity.clamp(1, opt.quantity.max(1));
12089 let intent_qty = if qty >= opt.quantity { None } else { Some(qty) };
12090 self.take_item_from_worker(
12091 &worker.instance_id,
12092 &worker.label,
12093 worker.x,
12094 worker.y,
12095 opt.item_instance_id,
12096 &opt.label,
12097 intent_qty,
12098 )
12099 .await?;
12100 Ok(())
12103 }
12104
12105 async fn take_item_from_worker(
12106 &mut self,
12107 worker_instance_id: &str,
12108 worker_label: &str,
12109 worker_x: f32,
12110 worker_y: f32,
12111 item_instance_id: uuid::Uuid,
12112 item_label: &str,
12113 quantity: Option<u32>,
12114 ) -> anyhow::Result<()> {
12115 let (px, py, _) = self.state.player_position_with_z();
12116 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
12117 if dist > WORKER_GIVE_RANGE_M {
12118 anyhow::bail!("worker {worker_label} too far — stand next to them");
12119 }
12120 self.seq += 1;
12121 self.session
12122 .submit_intent(Intent::TakeWorkerItem {
12123 entity_id: self.state.entity_id,
12124 worker_instance_id: worker_instance_id.to_string(),
12125 item_instance_id,
12126 quantity,
12127 seq: self.seq,
12128 })
12129 .await?;
12130 self.state.intents_sent += 1;
12131 let qty_note = quantity.map(|q| format!(" ×{q}")).unwrap_or_default();
12132 self.state.push_log(format!(
12133 "Taking {item_label}{qty_note} from {worker_label}…"
12134 ));
12135 Ok(())
12136 }
12137
12138 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
12139 if let Some(since) = self.state.pending_worker_hire_since {
12140 if since.elapsed() < WORKER_HIRE_PENDING_TIMEOUT {
12141 anyhow::bail!("hire request still pending — wait for the worker roster update");
12142 }
12143 self.state.pending_worker_hire_since = None;
12144 }
12145 if !self.state.has_worker_lodging() {
12146 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
12147 }
12148 self.seq += 1;
12149 self.session
12150 .submit_intent(Intent::HireWorker {
12151 entity_id: self.state.entity_id,
12152 def_id: "worker_laborer".into(),
12153 wage_copper_per_interval: 8,
12154 lodging_container_id: None,
12155 job_yaml: None,
12156 seq: self.seq,
12157 })
12158 .await?;
12159 self.state.intents_sent += 1;
12160 self.state.pending_worker_hire_since = Some(Instant::now());
12161 Ok(())
12162 }
12163
12164 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
12165 let Some(worker) = self
12166 .state
12167 .hired_workers
12168 .get(self.state.workers_menu_index)
12169 .cloned()
12170 else {
12171 anyhow::bail!("select a hired worker first");
12172 };
12173 let lodging = worker.lodging_container_id.clone().or_else(|| {
12174 crate::worker_route_editor::owned_lodging_container_ids(
12175 &self.state.placed_containers,
12176 self.state.character_id,
12177 )
12178 .into_iter()
12179 .next()
12180 .map(|(id, _)| id)
12181 });
12182 let label = worker.label.clone();
12183 let editor = if let Some(route) = &worker.route {
12184 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
12185 worker.instance_id,
12186 worker.label,
12187 route,
12188 lodging,
12189 )
12190 } else {
12191 crate::worker_route_editor::WorkerRouteEditorState::new(
12192 worker.instance_id,
12193 worker.label,
12194 lodging,
12195 )
12196 };
12197 self.state.worker_route_editor = Some(editor);
12198 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12199 if let Some(collapsed) =
12200 crate::client_config::ClientConfig::load().worker_route_panel_collapsed
12201 {
12202 ed.panel_collapsed = collapsed;
12203 }
12204 }
12205 self.state.show_workers_menu = false;
12206 self.state.push_log(format!(
12207 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
12208 ));
12209 Ok(())
12210 }
12211
12212 pub fn close_worker_route_editor(&mut self) {
12213 self.state.worker_route_editor = None;
12214 }
12215
12216 pub fn worker_route_editor_toggle_panel(&mut self) {
12217 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12218 ed.toggle_panel_collapsed();
12219 let collapsed = ed.panel_collapsed;
12220 let mut cfg = crate::client_config::ClientConfig::load();
12221 let _ = cfg.save_worker_route_panel_collapsed(collapsed);
12222 }
12223 }
12224
12225 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
12226 let n = {
12227 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12228 return;
12229 };
12230 ed.append_waypoint(x, y, z);
12231 ed.stop_count()
12232 };
12233 self.state
12234 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
12235 }
12236
12237 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
12240 let (px, py, _) = self.state.player_position_with_z();
12241 let inside = self.state.effective_inside_building();
12242 crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
12243 &self.state.placed_containers,
12244 &self.state.buildings,
12245 self.state.character_id,
12246 px,
12247 py,
12248 &self.state.hired_workers,
12249 inside.as_deref(),
12250 )
12251 }
12252
12253 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
12254 self.state.route_editor_node_candidates()
12255 }
12256
12257 fn re_open_harvest_picker(&mut self, index: usize, picked: std::collections::BTreeSet<String>) {
12258 use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
12259 let nodes = self.state.route_editor_node_candidates();
12260 let index = if nodes.is_empty() {
12261 ROUTE_PICKER_DONE_ROW
12262 } else {
12263 index.max(1).min(nodes.len())
12264 };
12265 self.re_open_sheet(S::HarvestPicker {
12266 index,
12267 picked,
12268 nodes,
12269 });
12270 }
12271
12272 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
12273 let (px, py, _) = self.state.player_position_with_z();
12274 let templates = self.re_template_candidates();
12275 crate::worker_route_editor::trade_npc_candidates(
12276 &self.state.npcs,
12277 px,
12278 py,
12279 &templates,
12280 )
12281 }
12282
12283 fn re_template_candidates(&self) -> Vec<String> {
12284 let mut extra = Vec::new();
12285 if let Some(ed) = self.state.worker_route_editor.as_ref() {
12286 for stop in &ed.stops {
12287 match stop {
12288 crate::worker_route_editor::WorkerRouteStop::DepositAt {
12289 filter: Some(filter),
12290 ..
12291 } => extra.extend(filter.iter().cloned()),
12292 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. }
12293 | crate::worker_route_editor::WorkerRouteStop::ListOnMarket {
12294 template,
12295 ..
12296 } => {
12297 extra.push(template.clone());
12298 }
12299 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
12300 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint)
12301 {
12302 extra.push(bp.output.clone());
12303 for input in &bp.inputs {
12304 extra.push(input.template_id.clone());
12305 }
12306 }
12307 }
12308 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
12309 for it in items {
12310 extra.push(it.template.clone());
12311 }
12312 }
12313 _ => {}
12314 }
12315 }
12316 if let Some(worker) = self
12318 .state
12319 .hired_workers
12320 .iter()
12321 .find(|w| w.instance_id == ed.worker_instance_id)
12322 {
12323 for recipe in &worker.known_blueprint_ids {
12324 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
12325 extra.push(bp.output.clone());
12326 }
12327 }
12328 for stack in &worker.inventory {
12329 if !stack.template_id.is_empty() && stack.quantity > 0 {
12330 extra.push(stack.template_id.clone());
12331 }
12332 }
12333 }
12334 }
12335 crate::worker_route_editor::route_item_template_candidates(
12336 &self.state.placed_containers,
12337 self.state.character_id,
12338 &self.state.inventory,
12339 &self.state.blueprints,
12340 &self.state.resource_nodes,
12341 &extra,
12342 Some(&self.state.item_catalog),
12343 )
12344 }
12345
12346 fn re_blueprint_ids(&self) -> Vec<String> {
12347 let worker_known: Option<&[String]> = self
12348 .state
12349 .worker_route_editor
12350 .as_ref()
12351 .and_then(|ed| {
12352 self.state
12353 .hired_workers
12354 .iter()
12355 .find(|w| w.instance_id == ed.worker_instance_id)
12356 })
12357 .map(|w| w.known_blueprint_ids.as_slice());
12358 crate::worker_route_editor::worker_craft_blueprint_ids(&self.state.blueprints, worker_known)
12359 }
12360
12361 fn re_bed_candidates(&self) -> Vec<(String, String)> {
12362 crate::worker_route_editor::owned_lodging_container_ids(
12363 &self.state.placed_containers,
12364 self.state.character_id,
12365 )
12366 }
12367
12368 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
12369 self.state
12370 .placed_containers
12371 .iter()
12372 .find(|c| c.id == container_id)
12373 .map(|c| c.contents.clone())
12374 .unwrap_or_default()
12375 }
12376
12377 fn re_sheet_supports_filter(&self) -> bool {
12380 use crate::worker_route_editor::RouteEditorSheet as S;
12381 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12382 matches!(
12383 ed.sheet,
12384 S::HarvestPicker { .. }
12385 | S::SellItem { .. }
12386 | S::MarketListItem { .. }
12387 | S::DepositFilter { .. }
12388 | S::WithdrawItems { .. }
12389 | S::WithdrawContainers { .. }
12390 | S::DepositContainers { .. }
12391 | S::SellNpcs { .. }
12392 | S::CraftBlueprint { .. }
12393 | S::BedPicker { .. }
12394 )
12395 })
12396 }
12397
12398 pub fn re_sheet_row_visible(&self, row: usize) -> bool {
12400 use crate::worker_route_editor::{
12401 harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
12402 ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12403 };
12404 let Some(ed) = self.state.worker_route_editor.as_ref() else {
12405 return false;
12406 };
12407 let filter = &ed.sheet_filter;
12408 match &ed.sheet {
12409 S::HarvestPicker { nodes, .. } => harvest_picker_row_matches(nodes, row, filter),
12410 S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12411 if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
12412 return true;
12413 }
12414 let slot = row.saturating_sub(2);
12415 templates.get(slot).is_some_and(|t| {
12416 let label = self.state.template_display_name(t);
12417 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12418 })
12419 }
12420 S::DepositFilter { rows, .. } => {
12421 if row >= rows.len() {
12422 return true;
12423 }
12424 rows.get(row).is_some_and(|(t, _)| {
12425 let label = self.state.template_display_name(t);
12426 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12427 })
12428 }
12429 S::WithdrawItems { lines, .. } => {
12430 if row >= lines.len() {
12431 return true;
12432 }
12433 lines.get(row).is_some_and(|l| {
12434 let label = self.state.template_display_name(&l.template);
12435 list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
12436 })
12437 }
12438 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12439 self.re_container_candidates().get(row).is_some_and(|c| {
12440 list_filter_row_matches(
12441 filter,
12442 Some(c.dist),
12443 &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
12444 )
12445 })
12446 }
12447 S::SellNpcs { .. } => {
12448 if row == 0 {
12449 return true;
12450 }
12451 self.re_npc_candidates().get(row - 1).is_some_and(|n| {
12452 list_filter_row_matches(
12453 filter,
12454 Some(n.dist),
12455 &[n.label.as_str(), n.id.as_str()],
12456 )
12457 })
12458 }
12459 S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
12460 let label = self
12461 .state
12462 .blueprints
12463 .iter()
12464 .find(|b| &b.id == id)
12465 .map(|b| {
12466 if b.label.is_empty() {
12467 id.as_str()
12468 } else {
12469 b.label.as_str()
12470 }
12471 })
12472 .unwrap_or(id.as_str());
12473 list_filter_row_matches(filter, None, &[id.as_str(), label])
12474 }),
12475 S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
12476 list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
12477 }),
12478 _ => true,
12479 }
12480 }
12481
12482 fn re_sheet_clamp_index(&mut self) {
12483 let count = self.re_sheet_row_count();
12484 if count == 0 {
12485 return;
12486 }
12487 let cur = self.re_sheet_index();
12488 if self.re_sheet_row_visible(cur) {
12489 return;
12490 }
12491 for offset in 1..count {
12492 if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
12493 self.re_sheet_set_index(cur + offset);
12494 return;
12495 }
12496 if cur >= offset && self.re_sheet_row_visible(cur - offset) {
12497 self.re_sheet_set_index(cur - offset);
12498 return;
12499 }
12500 }
12501 }
12502
12503 fn re_sheet_set_index(&mut self, index: usize) {
12504 use crate::worker_route_editor::RouteEditorSheet as S;
12505 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12506 return;
12507 };
12508 match &mut ed.sheet {
12509 S::AddMenu { index: slot }
12510 | S::WaypointMenu { index: slot }
12511 | S::HarvestPicker { index: slot, .. }
12512 | S::WithdrawContainers { index: slot }
12513 | S::DepositContainers { index: slot }
12514 | S::SellNpcs { index: slot }
12515 | S::CraftBlueprint { index: slot }
12516 | S::BedPicker { index: slot }
12517 | S::FarmPlotPicker { index: slot, .. }
12518 | S::FarmPlantSeed { index: slot, .. }
12519 | S::WithdrawItems { index: slot, .. }
12520 | S::DepositFilter { index: slot, .. }
12521 | S::SellItem { index: slot, .. } | S::MarketListItem { index: slot, .. } => *slot = index,
12522 _ => {}
12523 }
12524 }
12525
12526 pub fn re_focus_sheet_filter(&mut self) {
12527 if !self.re_sheet_supports_filter() {
12528 return;
12529 }
12530 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12531 ed.sheet_filter_focused = true;
12532 }
12533 }
12534
12535 pub fn re_blur_sheet_filter_keep_text(&mut self) {
12536 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12537 return;
12538 };
12539 if !ed.sheet_filter_focused {
12540 return;
12541 }
12542 ed.sheet_filter_focused = false;
12543 self.re_sheet_clamp_index();
12544 }
12545
12546 pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
12547 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12548 return false;
12549 };
12550 if ed.sheet_filter_focused {
12551 ed.sheet_filter_focused = false;
12552 self.re_sheet_clamp_index();
12553 return true;
12554 }
12555 if !ed.sheet_filter.is_empty() {
12556 ed.sheet_filter.clear();
12557 self.re_sheet_clamp_index();
12558 return true;
12559 }
12560 false
12561 }
12562
12563 pub fn re_append_sheet_filter_char(&mut self, ch: char) {
12564 if ch.is_control() {
12565 return;
12566 }
12567 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12568 return;
12569 };
12570 if !ed.sheet_filter_focused {
12571 return;
12572 }
12573 ed.sheet_filter.push(ch);
12574 self.re_sheet_set_index(0);
12575 self.re_sheet_clamp_index();
12576 }
12577
12578 pub fn re_sheet_filter_backspace(&mut self) {
12579 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12580 return;
12581 };
12582 if !ed.sheet_filter_focused {
12583 return;
12584 }
12585 ed.sheet_filter.pop();
12586 self.re_sheet_set_index(0);
12587 self.re_sheet_clamp_index();
12588 }
12589
12590 pub fn re_sheet_row_count(&self) -> usize {
12592 use crate::worker_route_editor::{
12593 harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
12594 };
12595 let Some(ed) = self.state.worker_route_editor.as_ref() else {
12596 return 0;
12597 };
12598 match &ed.sheet {
12599 S::Stops => ed.stops.len(),
12600 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
12601 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
12602 S::WaypointMapPick => 0,
12603 S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
12604 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12605 self.re_container_candidates().len()
12606 }
12607 S::WithdrawItems { lines, .. } => lines.len() + 1, S::DepositFilter { rows, .. } => rows.len() + 1, S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12611 sell_item_picker_row_count(templates.len())
12612 }
12613 S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
12614 S::WaitEntry { .. } => 1,
12615 S::BedPicker { .. } => self.re_bed_candidates().len(),
12616 S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
12617 S::FarmPlantSeed { seeds, .. } => seeds.len(),
12618 }
12619 }
12620
12621 pub fn re_sheet_index(&self) -> usize {
12623 use crate::worker_route_editor::RouteEditorSheet as S;
12624 let Some(ed) = self.state.worker_route_editor.as_ref() else {
12625 return 0;
12626 };
12627 match &ed.sheet {
12628 S::AddMenu { index }
12629 | S::WaypointMenu { index }
12630 | S::HarvestPicker { index, .. }
12631 | S::WithdrawContainers { index }
12632 | S::DepositContainers { index }
12633 | S::SellNpcs { index }
12634 | S::CraftBlueprint { index }
12635 | S::BedPicker { index }
12636 | S::FarmPlotPicker { index, .. }
12637 | S::FarmPlantSeed { index, .. }
12638 | S::WithdrawItems { index, .. }
12639 | S::DepositFilter { index, .. }
12640 | S::SellItem { index, .. } | S::MarketListItem { index, .. } => *index,
12641 _ => 0,
12642 }
12643 }
12644
12645 pub fn re_sheet_move(&mut self, delta: i32) {
12647 let count = self.re_sheet_row_count();
12648 if count == 0 {
12649 return;
12650 }
12651 let cur = self.re_sheet_index();
12652 let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
12653 self.re_sheet_set_index(next);
12654 }
12655
12656 pub fn re_sheet_page(&mut self, pages: i32) {
12657 let count = self.re_sheet_row_count();
12658 if count == 0 {
12659 return;
12660 }
12661 let cur = self.re_sheet_index();
12662 let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
12663 self.re_sheet_set_index(next);
12664 }
12665
12666 pub fn re_sheet_adjust(&mut self, delta: i32) {
12668 use crate::worker_route_editor::RouteEditorSheet as S;
12669 let index = self.re_sheet_index();
12670 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12671 return;
12672 };
12673 match &mut ed.sheet {
12674 S::WithdrawItems { lines, .. } => {
12675 if let Some(line) = lines.get_mut(index) {
12676 line.adjust_qty(delta);
12677 }
12678 }
12679 S::WaitEntry { ticks } => {
12680 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
12681 }
12682 _ => {}
12683 }
12684 }
12685
12686 pub fn re_sheet_back(&mut self) {
12687 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12688 return;
12689 };
12690 use crate::worker_route_editor::RouteEditorSheet as S;
12691 let was_editing = ed.editing_index.is_some();
12692 let from_top_picker = matches!(
12693 ed.sheet,
12694 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
12695 );
12696 ed.sheet_back();
12697 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
12698 self.state
12700 .push_log("Route: left edit sheet — press s to save current stops".to_string());
12701 }
12702 }
12703
12704 pub fn re_at_root_sheet(&self) -> bool {
12706 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12707 matches!(
12708 ed.sheet,
12709 crate::worker_route_editor::RouteEditorSheet::Stops
12710 )
12711 })
12712 }
12713
12714 pub fn re_open_add_menu(&mut self) {
12715 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12716 ed.open_add_menu();
12717 }
12718 }
12719
12720 pub fn re_open_bed_picker(&mut self) {
12721 let beds = self.re_bed_candidates();
12722 if beds.is_empty() {
12723 self.state
12724 .push_log("Route: place a camp bed first".to_string());
12725 return;
12726 }
12727 let current = self
12728 .state
12729 .worker_route_editor
12730 .as_ref()
12731 .and_then(|ed| ed.lodging_container_id.clone());
12732 let index = current
12733 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
12734 .unwrap_or(0);
12735 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
12736 }
12737
12738 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
12739 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12740 ed.open_sheet(sheet);
12741 }
12742 }
12743
12744 fn re_confirm_stop(&mut self, stop: crate::worker_route_editor::WorkerRouteStop, what: String) {
12746 let appended = self
12747 .state
12748 .worker_route_editor
12749 .as_mut()
12750 .is_some_and(|ed| ed.confirm_stop(stop));
12751 if appended {
12752 self.state.push_log(format!("Route: + {what}"));
12753 } else {
12754 self.state
12755 .push_log(format!("Route: {what} already in route — selected it"));
12756 }
12757 }
12758
12759 fn re_open_withdraw_items(&mut self, container_id: String) {
12760 use crate::worker_route_editor::{
12761 RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12762 };
12763 let contents = self.re_container_contents(&container_id);
12764 let existing = self
12768 .state
12769 .worker_route_editor
12770 .as_ref()
12771 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12772 .and_then(|stop| match stop {
12773 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
12774 _ => None,
12775 })
12776 .unwrap_or_default();
12777 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
12778 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12781 let _ = ed.retarget_withdraw_container(container_id.clone());
12782 }
12783 self.re_open_sheet(S::WithdrawItems {
12784 container_id,
12785 lines,
12786 index: 0,
12787 });
12788 }
12789
12790 fn re_withdraw_items_activate(&mut self, index: usize) {
12791 use crate::worker_route_editor::{
12792 RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12793 };
12794 enum Outcome {
12795 Cycled,
12796 Confirmed(String),
12797 Empty,
12798 }
12799 let outcome = {
12800 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12801 return;
12802 };
12803 let S::WithdrawItems {
12804 container_id,
12805 lines,
12806 index: sheet_index,
12807 } = &mut ed.sheet
12808 else {
12809 return;
12810 };
12811 *sheet_index = index;
12812 if index < lines.len() {
12813 lines[index].cycle();
12814 Outcome::Cycled
12815 } else {
12816 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
12817 if items.is_empty() {
12818 Outcome::Empty
12819 } else {
12820 let stop = WorkerRouteStop::WithdrawFrom {
12821 container_id: container_id.clone(),
12822 items,
12823 };
12824 let summary = stop.summary();
12825 ed.confirm_stop(stop);
12826 Outcome::Confirmed(summary)
12827 }
12828 }
12829 };
12830 match outcome {
12831 Outcome::Cycled => {}
12832 Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
12833 Outcome::Empty => self.state.push_log(
12834 "Route: pick at least one item (Space/Enter toggles All/qty)".to_string(),
12835 ),
12836 }
12837 }
12838
12839 fn re_open_deposit_filter(&mut self, container_id: String) {
12840 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12841 let existing_filter = self
12843 .state
12844 .worker_route_editor
12845 .as_ref()
12846 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12847 .and_then(|stop| match stop {
12848 WorkerRouteStop::DepositAt { filter, .. } => {
12849 Some(filter.clone().unwrap_or_default())
12850 }
12851 _ => None,
12852 });
12853 let mut candidates = self.re_template_candidates();
12854 if let Some(ref chosen) = existing_filter {
12855 for t in chosen {
12856 if !candidates.iter().any(|c| c == t) {
12857 candidates.push(t.clone());
12858 }
12859 }
12860 candidates.sort();
12861 candidates.dedup();
12862 }
12863 let rows: Vec<(String, bool)> = match existing_filter {
12864 Some(chosen) => candidates
12865 .iter()
12866 .map(|t| (t.clone(), chosen.contains(t)))
12867 .collect(),
12868 None => candidates.into_iter().map(|t| (t, false)).collect(),
12869 };
12870 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12871 let _ = ed.retarget_deposit_container(container_id.clone());
12872 }
12873 self.re_open_sheet(S::DepositFilter {
12874 container_id,
12875 rows,
12876 index: 0,
12877 });
12878 }
12879
12880 fn re_deposit_filter_activate(&mut self, index: usize) {
12881 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12882 let mut confirmed: Option<String> = None;
12883 {
12884 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12885 return;
12886 };
12887 let S::DepositFilter {
12888 container_id,
12889 rows,
12890 index: sheet_index,
12891 } = &mut ed.sheet
12892 else {
12893 return;
12894 };
12895 *sheet_index = index;
12896 if index < rows.len() {
12897 rows[index].1 = !rows[index].1;
12898 } else {
12899 let chosen: Vec<String> = rows
12901 .iter()
12902 .filter(|(_, on)| *on)
12903 .map(|(t, _)| t.clone())
12904 .collect();
12905 let filter = if chosen.is_empty() {
12906 None
12907 } else {
12908 Some(chosen)
12909 };
12910 let stop = WorkerRouteStop::DepositAt {
12911 container_id: container_id.clone(),
12912 filter,
12913 };
12914 confirmed = Some(stop.summary());
12915 ed.confirm_stop(stop);
12916 }
12917 }
12918 if let Some(what) = confirmed {
12919 self.state.push_log(format!("Route: + {what}"));
12920 }
12921 }
12922
12923 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
12924 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12925 let (pre_npc, pre_template, pre_all) = self
12927 .state
12928 .worker_route_editor
12929 .as_ref()
12930 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12931 .and_then(|stop| match stop {
12932 WorkerRouteStop::TradeWith {
12933 npc_id,
12934 template,
12935 sell_all,
12936 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
12937 _ => None,
12938 })
12939 .unwrap_or((None, None, true));
12940 let npc_id = npc_id.or(pre_npc);
12941 let mut templates = crate::worker_route_editor::sellable_route_item_template_candidates(
12942 &self.re_template_candidates(),
12943 &self.state.npcs,
12944 npc_id.as_deref(),
12945 );
12946 if let Some(template) = pre_template.as_ref() {
12949 if !templates.iter().any(|candidate| candidate == template) {
12950 templates.push(template.clone());
12951 templates.sort();
12952 }
12953 }
12954 if templates.is_empty() {
12955 let msg = crate::worker_route_editor::sell_merchant_empty_reason(
12956 npc_id.as_deref(),
12957 &self.state.npcs,
12958 &self.re_template_candidates(),
12959 );
12960 self.state.push_log(msg);
12961 return;
12962 }
12963 let mut picked = std::collections::BTreeSet::new();
12964 if let Some(t) = pre_template {
12965 picked.insert(t);
12966 }
12967 self.re_open_sheet(S::SellItem {
12968 npc_id,
12969 templates,
12970 index: if picked.is_empty() {
12971 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
12972 } else {
12973 2
12974 },
12975 sell_all: pre_all,
12976 picked,
12977 });
12978 }
12979
12980 fn re_sell_item_activate(&mut self, index: usize) {
12981 use crate::worker_route_editor::{
12982 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12983 };
12984 let mut batch_log: Option<String> = None;
12985 {
12986 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12987 return;
12988 };
12989 let S::SellItem {
12990 npc_id,
12991 templates,
12992 index: sheet_index,
12993 sell_all,
12994 picked,
12995 } = &mut ed.sheet
12996 else {
12997 return;
12998 };
12999 *sheet_index = index;
13000 if index == ROUTE_PICKER_DONE_ROW {
13001 if picked.is_empty() {
13002 batch_log =
13003 Some("Route: pick at least one item (Space toggles, Done confirms)".into());
13004 } else {
13005 let picks: Vec<String> = picked.iter().cloned().collect();
13006 let npc = npc_id.clone();
13007 let all = *sell_all;
13008 let added = ed.confirm_trade_picks(npc, &picks, all);
13009 batch_log = Some(format!("Route: + {added} sell stop(s)"));
13010 }
13011 } else if index == SELL_ITEM_TOGGLE_ROW {
13012 *sell_all = !*sell_all;
13013 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13014 let sellable = crate::worker_route_editor::sellable_route_item_template_candidates(
13015 std::slice::from_ref(template),
13016 &self.state.npcs,
13017 npc_id.as_deref(),
13018 )
13019 .iter()
13020 .any(|candidate| candidate == template);
13021 if !sellable && !picked.contains(template) {
13022 return;
13023 }
13024 if picked.contains(template) {
13025 picked.remove(template);
13026 } else {
13027 picked.insert(template.clone());
13028 }
13029 }
13030 }
13031 if let Some(msg) = batch_log {
13032 self.state.push_log(msg);
13033 }
13034 }
13035
13036 fn re_open_market_list_item(&mut self) {
13037 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13038 let (pre_hall, pre_template, pre_all) = self
13039 .state
13040 .worker_route_editor
13041 .as_ref()
13042 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13043 .and_then(|stop| match stop {
13044 WorkerRouteStop::ListOnMarket {
13045 hall_id,
13046 template,
13047 list_all,
13048 } => Some((hall_id.clone(), Some(template.clone()), *list_all)),
13049 _ => None,
13050 })
13051 .unwrap_or((None, None, true));
13052 let mut templates = self.re_template_candidates();
13053 templates.sort_by_key(|t| {
13056 std::cmp::Reverse(self.state.item_base_value_copper_hint(t).unwrap_or(0))
13057 });
13058 if let Some(template) = pre_template.as_ref() {
13059 if !templates.iter().any(|c| c == template) {
13060 templates.push(template.clone());
13061 }
13062 }
13063 if templates.is_empty() {
13064 self.state.push_log("Route: no item templates available for market list".to_string());
13065 return;
13066 }
13067 let mut picked = std::collections::BTreeSet::new();
13068 if let Some(t) = pre_template {
13069 picked.insert(t);
13070 }
13071 self.re_open_sheet(S::MarketListItem {
13072 hall_id: pre_hall,
13073 templates,
13074 index: if picked.is_empty() {
13075 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
13076 } else {
13077 2
13078 },
13079 list_all: pre_all,
13080 picked,
13081 });
13082 }
13083
13084 fn re_market_list_item_activate(&mut self, index: usize) {
13085 use crate::worker_route_editor::{
13086 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
13087 };
13088 let mut batch_log: Option<String> = None;
13089 {
13090 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13091 return;
13092 };
13093 let S::MarketListItem {
13094 hall_id,
13095 templates,
13096 index: sheet_index,
13097 list_all,
13098 picked,
13099 } = &mut ed.sheet
13100 else {
13101 return;
13102 };
13103 *sheet_index = index;
13104 if index == ROUTE_PICKER_DONE_ROW {
13105 if picked.is_empty() {
13106 batch_log = Some(
13107 "Route: pick at least one item (Space toggles, Done confirms)".into(),
13108 );
13109 } else {
13110 let picks: Vec<String> = picked.iter().cloned().collect();
13111 let hall = hall_id.clone();
13112 let all = *list_all;
13113 let added = ed.confirm_market_list_picks(hall, &picks, all);
13114 batch_log = Some(format!("Route: + {added} market-list stop(s)"));
13115 }
13116 } else if index == SELL_ITEM_TOGGLE_ROW {
13117 *list_all = !*list_all;
13118 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13119 if picked.contains(template) {
13120 picked.remove(template);
13121 } else {
13122 picked.insert(template.clone());
13123 }
13124 }
13125 }
13126 if let Some(msg) = batch_log {
13127 self.state.push_log(msg);
13128 }
13129 }
13130
13131 pub fn re_edit_selected_stop(&mut self) {
13133 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13134 let Some(stop) = self
13135 .state
13136 .worker_route_editor
13137 .as_ref()
13138 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
13139 else {
13140 self.state
13141 .push_log("Route: no stop selected — press a to add one".to_string());
13142 return;
13143 };
13144 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13145 ed.begin_edit_selected();
13146 }
13147 match stop {
13148 WorkerRouteStop::Waypoint { .. } => {
13149 self.re_open_sheet(S::WaypointMenu { index: 0 });
13150 }
13151 WorkerRouteStop::HarvestNode { node_id } => {
13152 let nodes = self.state.route_editor_node_candidates();
13153 if nodes.is_empty() {
13154 self.re_cancel_edit();
13155 self.state
13156 .push_log("Route: no harvestable nodes visible to retarget".to_string());
13157 } else {
13158 let mut picked = std::collections::BTreeSet::new();
13159 picked.insert(node_id.clone());
13160 let index = nodes
13161 .iter()
13162 .position(|n| n.id == node_id)
13163 .map(|i| i + 1)
13164 .unwrap_or(1);
13165 self.re_open_harvest_picker(index, picked);
13166 }
13167 }
13168 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
13169 let containers = self.re_container_candidates();
13172 if containers.is_empty() {
13173 self.re_cancel_edit();
13174 self.state
13175 .push_log("Route: place a storage chest first".to_string());
13176 } else {
13177 let index = containers
13178 .iter()
13179 .position(|c| c.id == container_id)
13180 .unwrap_or(0);
13181 self.re_open_sheet(S::WithdrawContainers { index });
13182 }
13183 }
13184 WorkerRouteStop::DepositAt { container_id, .. } => {
13185 let containers = self.re_container_candidates();
13186 if containers.is_empty() {
13187 self.re_cancel_edit();
13188 self.state
13189 .push_log("Route: place a storage chest first".to_string());
13190 } else {
13191 let index = containers
13192 .iter()
13193 .position(|c| c.id == container_id)
13194 .unwrap_or(0);
13195 self.re_open_sheet(S::DepositContainers { index });
13196 }
13197 }
13198 WorkerRouteStop::TradeWith { npc_id, .. } => {
13199 let npcs = self.re_npc_candidates();
13200 let index = npc_id
13202 .as_ref()
13203 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
13204 .unwrap_or(0);
13205 self.re_open_sheet(S::SellNpcs { index });
13206 }
13207 WorkerRouteStop::ListOnMarket { .. } => {
13208 self.re_open_market_list_item();
13209 }
13210 WorkerRouteStop::CraftAt { blueprint, .. } => {
13211 let bps = self.re_blueprint_ids();
13212 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
13213 if bps.is_empty() {
13214 self.re_cancel_edit();
13215 self.state
13216 .push_log("Route: no known blueprints to retarget".to_string());
13217 } else {
13218 self.re_open_sheet(S::CraftBlueprint { index });
13219 }
13220 }
13221 WorkerRouteStop::CultivatePlot { .. } => {
13222 self.re_open_farm_plot_picker(
13223 crate::worker_route_editor::FarmPlotAction::Cultivate,
13224 );
13225 }
13226 WorkerRouteStop::PlantPlot { .. } => {
13227 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
13228 }
13229 WorkerRouteStop::HarvestPlot { .. } => {
13230 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
13231 }
13232 WorkerRouteStop::RestIfNeeded => {
13233 self.re_cancel_edit();
13234 self.state
13235 .push_log("Route: rest has no settings (change the bed with l)".to_string());
13236 }
13237 WorkerRouteStop::Wait { wait_ticks } => {
13238 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
13239 }
13240 }
13241 }
13242
13243 fn re_cancel_edit(&mut self) {
13244 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13245 ed.editing_index = None;
13246 }
13247 }
13248
13249 pub fn worker_route_editor_ui_click(
13252 &mut self,
13253 click: crate::worker_route_editor::RouteEditorClick,
13254 ) {
13255 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
13256 match click {
13257 RouteEditorClick::SelectStop(i) => {
13258 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13259 ed.sheet = S::Stops;
13260 ed.select_stop(i);
13261 }
13262 }
13263 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
13264 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
13265 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
13266 }
13267 }
13268
13269 pub fn re_sheet_row_activate(&mut self, row: usize) {
13271 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13272 let Some(sheet) = self
13273 .state
13274 .worker_route_editor
13275 .as_ref()
13276 .map(|ed| ed.sheet.clone())
13277 else {
13278 return;
13279 };
13280 match sheet {
13281 S::Stops => {
13282 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13283 ed.select_stop(row);
13284 }
13285 }
13286 S::AddMenu { .. } => match row {
13287 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
13288 1 => {
13289 if self.re_node_candidates().is_empty() {
13290 self.state.push_log(
13291 "Route: no harvestable nodes visible in this region".to_string(),
13292 );
13293 } else {
13294 self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
13295 }
13296 }
13297 2 | 3 => {
13298 if self.re_container_candidates().is_empty() {
13299 self.state
13300 .push_log("Route: place a storage chest first".to_string());
13301 } else if row == 2 {
13302 self.re_open_sheet(S::WithdrawContainers { index: 0 });
13303 } else {
13304 self.re_open_sheet(S::DepositContainers { index: 0 });
13305 }
13306 }
13307 4 => {
13308 if self.re_template_candidates().is_empty() {
13309 self.state.push_log(
13310 "Route: no item templates available — learn a craft recipe or place a harvest node first"
13311 .to_string(),
13312 );
13313 } else {
13314 self.re_open_sheet(S::SellNpcs { index: 0 });
13315 }
13316 }
13317 5 => {
13318 if self.re_template_candidates().is_empty() {
13319 self.state.push_log(
13320 "Route: no item templates available — learn a craft recipe or place a harvest node first"
13321 .to_string(),
13322 );
13323 } else {
13324 self.re_open_market_list_item();
13325 }
13326 }
13327 6 => {
13328 if self.re_blueprint_ids().is_empty() {
13329 self.state.push_log(
13330 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
13331 .to_string(),
13332 );
13333 } else {
13334 self.re_open_sheet(S::CraftBlueprint { index: 0 });
13335 }
13336 }
13337 7 => self.re_confirm_stop(
13338 WorkerRouteStop::RestIfNeeded,
13339 "rest at lodging (if needed)".into(),
13340 ),
13341 8 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
13342 9 => self.re_open_farm_plot_picker(
13343 crate::worker_route_editor::FarmPlotAction::Cultivate,
13344 ),
13345 10 => {
13346 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant)
13347 }
13348 11 => self
13349 .re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
13350 _ => {}
13351 },
13352 S::WaypointMenu { .. } => match row {
13353 0 => {
13354 let (x, y, z) = self.state.player_position_with_z();
13355 let stop = WorkerRouteStop::Waypoint { x, y, z };
13356 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13357 }
13358 1 => {
13359 self.re_open_sheet(S::WaypointMapPick);
13360 self.state.push_log(
13361 "Route: click the map to place the waypoint (Esc to finish)".to_string(),
13362 );
13363 }
13364 _ => {}
13365 },
13366 S::HarvestPicker { .. } => {
13367 let mut log: Option<String> = None;
13368 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13369 let S::HarvestPicker {
13370 index: sheet_index,
13371 picked,
13372 nodes,
13373 } = &mut ed.sheet
13374 else {
13375 return;
13376 };
13377 *sheet_index = row;
13378 if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
13379 if picked.is_empty() {
13380 log = Some(
13381 "Route: pick at least one node (Space toggles, Done confirms)"
13382 .into(),
13383 );
13384 } else {
13385 let ids: Vec<String> = picked.iter().cloned().collect();
13386 let added = ed.confirm_harvest_picks(&ids);
13387 log = Some(format!("Route: + {added} harvest stop(s)"));
13388 }
13389 } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
13390 if picked.contains(&n.id) {
13391 picked.remove(&n.id);
13392 } else {
13393 picked.insert(n.id.clone());
13394 }
13395 }
13396 }
13397 if let Some(msg) = log {
13398 self.state.push_log(msg);
13399 }
13400 }
13401 S::WithdrawContainers { .. } => {
13402 let containers = self.re_container_candidates();
13403 if let Some(c) = containers.get(row) {
13404 let id = c.id.clone();
13405 self.re_open_withdraw_items(id);
13406 }
13407 }
13408 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
13409 S::DepositContainers { .. } => {
13410 let containers = self.re_container_candidates();
13411 if let Some(c) = containers.get(row) {
13412 let id = c.id.clone();
13413 self.re_open_deposit_filter(id);
13414 }
13415 }
13416 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
13417 S::SellNpcs { .. } => {
13418 let templates = self.re_template_candidates();
13419 let npcs = self.re_npc_candidates();
13420 if row == 0 {
13421 if !crate::worker_route_editor::any_trade_npc_buys_route_item(
13422 &self.state.npcs,
13423 &templates,
13424 ) {
13425 self.state.push_log(
13426 crate::worker_route_editor::sell_merchant_empty_reason(
13427 None,
13428 &self.state.npcs,
13429 &templates,
13430 ),
13431 );
13432 return;
13433 }
13434 self.re_open_sell_item(None);
13435 return;
13436 }
13437 let Some(n) = npcs.get(row - 1) else {
13438 return;
13439 };
13440 if !n.buys_route_item {
13441 self.state.push_log(
13442 crate::worker_route_editor::sell_merchant_empty_reason(
13443 Some(n.id.as_str()),
13444 &self.state.npcs,
13445 &templates,
13446 ),
13447 );
13448 return;
13449 }
13450 self.re_open_sell_item(Some(n.id.clone()));
13451 }
13452 S::SellItem { .. } => self.re_sell_item_activate(row),
13453 S::MarketListItem { .. } => self.re_market_list_item_activate(row),
13454 S::CraftBlueprint { .. } => {
13455 let bps = self.re_blueprint_ids();
13456 if let Some(bp) = bps.get(row) {
13457 let stop = WorkerRouteStop::CraftAt {
13458 device: "hand".into(),
13459 blueprint: bp.clone(),
13460 qty: None,
13461 };
13462 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
13463 }
13464 }
13465 S::WaitEntry { ticks } => {
13466 let stop = WorkerRouteStop::Wait { wait_ticks: ticks };
13467 self.re_confirm_stop(stop, format!("wait {ticks}t"));
13468 }
13469 S::BedPicker { .. } => {
13470 let beds = self.re_bed_candidates();
13471 if let Some((id, name)) = beds.get(row) {
13472 let (id, name) = (id.clone(), name.clone());
13473 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13474 ed.lodging_container_id = Some(id.clone());
13475 ed.sheet = S::Stops;
13476 }
13477 self.state
13478 .push_log(format!("Route: rest bed set to {name}"));
13479 }
13480 }
13481 S::FarmPlotPicker { action, .. } => {
13482 let plots = self.re_farm_plot_candidates();
13483 let Some(plot) = plots.get(row).cloned() else {
13484 return;
13485 };
13486 match action {
13487 crate::worker_route_editor::FarmPlotAction::Cultivate => {
13488 let label = plot_route_label(&plot);
13489 self.re_confirm_stop(
13490 WorkerRouteStop::CultivatePlot {
13491 plot_id: plot.plot_id,
13492 },
13493 format!("cultivate {label}"),
13494 );
13495 }
13496 crate::worker_route_editor::FarmPlotAction::Harvest => {
13497 let label = plot_route_label(&plot);
13498 self.re_confirm_stop(
13499 WorkerRouteStop::HarvestPlot {
13500 plot_id: plot.plot_id,
13501 },
13502 format!("harvest {label}"),
13503 );
13504 }
13505 crate::worker_route_editor::FarmPlotAction::Plant => {
13506 let seeds = self.re_farm_seed_candidates();
13507 if seeds.is_empty() {
13508 self.state.push_log(
13509 "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
13510 );
13511 return;
13512 }
13513 self.re_open_sheet(S::FarmPlantSeed {
13514 plot_id: plot.plot_id,
13515 seeds,
13516 index: 0,
13517 });
13518 }
13519 }
13520 }
13521 S::FarmPlantSeed { plot_id, seeds, .. } => {
13522 if let Some(seed) = seeds.get(row).cloned() {
13523 self.re_confirm_stop(
13524 WorkerRouteStop::PlantPlot {
13525 plot_id,
13526 seed_template: seed.clone(),
13527 },
13528 format!("plant {seed}"),
13529 );
13530 }
13531 }
13532 S::WaypointMapPick => {}
13533 }
13534 }
13535
13536 fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
13537 use crate::worker_route_editor::RouteEditorSheet as S;
13538 if self.re_farm_plot_candidates().is_empty() {
13539 self.state
13540 .push_log("Route: no farmable plots visible — claim land or get farm access first");
13541 return;
13542 }
13543 self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
13544 }
13545
13546 fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
13547 self.state
13548 .property_plots
13549 .iter()
13550 .filter(|p| p.is_mine || p.may_farm)
13551 .cloned()
13552 .collect()
13553 }
13554
13555 fn re_farm_seed_candidates(&self) -> Vec<String> {
13559 let mut set = std::collections::BTreeSet::new();
13560 let looks_like_seed = |id: &str, catalog: &std::collections::HashMap<String, ItemCatalogEntryView>| {
13561 catalog.get(id).is_some_and(|e| e.is_farm_seed()) || id.ends_with("_seed")
13562 };
13563 for (id, _, _) in self.state.farm_seed_entries() {
13564 set.insert(id);
13565 }
13566 for c in &self.state.placed_containers {
13567 let mine = match (self.state.character_id, c.owner_character_id) {
13568 (Some(a), Some(b)) => a == b,
13569 _ => false,
13570 };
13571 if !mine {
13572 continue;
13573 }
13574 for s in &c.contents {
13575 if s.quantity > 0
13576 && (s.props.contains_key("seed_for")
13577 || looks_like_seed(&s.template_id, &self.state.item_catalog))
13578 {
13579 set.insert(s.template_id.clone());
13580 }
13581 }
13582 }
13583 if let Some(ed) = self.state.worker_route_editor.as_ref() {
13584 for stop in &ed.stops {
13585 if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } =
13586 stop
13587 {
13588 for it in items {
13589 if looks_like_seed(&it.template, &self.state.item_catalog) {
13590 set.insert(it.template.clone());
13591 }
13592 }
13593 }
13594 if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
13595 seed_template,
13596 ..
13597 } = stop
13598 {
13599 if !seed_template.is_empty() {
13600 set.insert(seed_template.clone());
13601 }
13602 }
13603 }
13604 }
13605 for (id, entry) in &self.state.item_catalog {
13606 if entry.is_farm_seed() {
13607 set.insert(id.clone());
13608 }
13609 }
13610 set.into_iter().collect()
13611 }
13612
13613 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
13620 use crate::worker_route_editor as wre;
13621 use wre::RouteEditorSheet as S;
13622 if self.state.worker_route_editor.is_none() {
13623 return;
13624 }
13625 let sheet = self
13626 .state
13627 .worker_route_editor
13628 .as_ref()
13629 .map(|ed| ed.sheet.clone())
13630 .unwrap_or(S::Stops);
13631 match sheet {
13632 S::WaypointMapPick => {
13633 let (_, _, z) = self.state.player_position_with_z();
13634 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
13635 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13636 let editing = self
13638 .state
13639 .worker_route_editor
13640 .as_ref()
13641 .is_some_and(|ed| ed.editing_index.is_some());
13642 if !editing {
13643 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13644 ed.sheet = S::WaypointMapPick;
13645 }
13646 }
13647 }
13648 S::HarvestPicker { .. } => {
13649 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13650 let mut log: Option<String> = None;
13651 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13652 let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
13653 return;
13654 };
13655 let selected = if picked.contains(&node.id) {
13656 picked.remove(&node.id);
13657 false
13658 } else {
13659 picked.insert(node.id.clone());
13660 true
13661 };
13662 log = Some(format!(
13663 "Route: {} {}",
13664 if selected { "selected" } else { "deselected" },
13665 resource_node_route_label(node)
13666 ));
13667 }
13668 if let Some(msg) = log {
13669 self.state.push_log(msg);
13670 }
13671 }
13672 }
13673 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
13674 let inside = self.state.effective_inside_building();
13676 if let Some(cid) = wre::pick_storage_container_at(
13677 &self.state.placed_containers,
13678 self.state.character_id,
13679 x,
13680 y,
13681 inside.as_deref(),
13682 ) {
13683 self.re_open_withdraw_items(cid);
13684 }
13685 }
13686 S::DepositContainers { .. } | S::DepositFilter { .. } => {
13687 let inside = self.state.effective_inside_building();
13688 if let Some(cid) = wre::pick_storage_container_at(
13689 &self.state.placed_containers,
13690 self.state.character_id,
13691 x,
13692 y,
13693 inside.as_deref(),
13694 ) {
13695 self.re_open_deposit_filter(cid);
13696 }
13697 }
13698 S::SellNpcs { .. } => {
13699 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13700 self.re_open_sell_item(Some(npc_id));
13701 }
13702 }
13703 S::SellItem { .. } => {
13704 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13705 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13706 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
13707 *slot = Some(npc_id.clone());
13708 }
13709 }
13710 self.state
13711 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13712 }
13713 }
13714 _ => self.worker_route_editor_quick_add_click(x, y),
13716 }
13717 }
13718
13719 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
13723 use crate::worker_route_editor as wre;
13724 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
13725 let dx = ax - bx;
13726 let dy = ay - by;
13727 (dx * dx + dy * dy).sqrt()
13728 };
13729
13730 let selected_stop_kind = self
13733 .state
13734 .worker_route_editor
13735 .as_ref()
13736 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
13737 .map(|s| match s {
13738 wre::WorkerRouteStop::TradeWith { .. } => 1,
13739 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
13740 _ => 0,
13741 })
13742 .unwrap_or(0);
13743 if selected_stop_kind == 1 {
13744 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13745 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13746 ed.set_selected_trade_npc(npc_id.clone());
13747 }
13748 self.state
13749 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13750 return;
13751 }
13752 }
13753 if selected_stop_kind == 2 {
13754 let inside = self.state.effective_inside_building();
13755 if let Some(cid) = wre::pick_storage_container_at(
13756 &self.state.placed_containers,
13757 self.state.character_id,
13758 x,
13759 y,
13760 inside.as_deref(),
13761 ) {
13762 let name = self
13763 .state
13764 .placed_containers
13765 .iter()
13766 .find(|c| c.id == cid)
13767 .map(|c| c.display_name.clone())
13768 .unwrap_or_else(|| "container".into());
13769 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13770 ed.set_selected_withdraw_container(cid.clone());
13771 }
13772 self.state
13773 .push_log(format!("Route: withdraw source → {name}"));
13774 return;
13775 }
13776 }
13777
13778 enum Target {
13781 Bed(String),
13782 Container(String),
13783 Npc(String, String),
13784 Node(String, String),
13785 }
13786 let mut best: Option<(f32, u8, Target)> = None;
13787 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
13788 let better = match best {
13789 None => true,
13790 Some((bd, brank, _)) => {
13791 d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank)
13792 }
13793 };
13794 if better {
13795 *best = Some((d, rank, t));
13796 }
13797 };
13798 let inside = self.state.effective_inside_building();
13799 if let Some(bed_id) = wre::pick_lodging_container_at(
13800 &self.state.placed_containers,
13801 self.state.character_id,
13802 x,
13803 y,
13804 inside.as_deref(),
13805 ) {
13806 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
13807 let already_bed =
13810 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
13811 ed.lodging_container_id.as_deref() == Some(bed_id.as_str())
13812 });
13813 if already_bed {
13814 consider(
13815 dist(x, y, c.x, c.y),
13816 1,
13817 Target::Container(bed_id),
13818 &mut best,
13819 );
13820 } else {
13821 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
13822 }
13823 }
13824 }
13825 if let Some(cid) = wre::pick_storage_container_at(
13826 &self.state.placed_containers,
13827 self.state.character_id,
13828 x,
13829 y,
13830 inside.as_deref(),
13831 ) {
13832 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
13833 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
13834 }
13835 }
13836 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13837 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
13838 consider(
13839 dist(x, y, n.x, n.y),
13840 2,
13841 Target::Npc(npc_id, label),
13842 &mut best,
13843 );
13844 }
13845 }
13846 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13847 let d = dist(x, y, node.x, node.y);
13848 let label = resource_node_route_label(node);
13849 consider(d, 3, Target::Node(node.id.clone(), label), &mut best);
13850 }
13851
13852 match best.map(|(_, _, t)| t) {
13853 Some(Target::Bed(bed_id)) => {
13854 let name = self
13855 .state
13856 .placed_containers
13857 .iter()
13858 .find(|c| c.id == bed_id)
13859 .map(|c| c.display_name.clone())
13860 .unwrap_or_else(|| "camp bed".into());
13861 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13862 ed.lodging_container_id = Some(bed_id.clone());
13863 }
13864 self.state
13865 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
13866 }
13867 Some(Target::Container(cid)) => {
13868 let name = self
13869 .state
13870 .placed_containers
13871 .iter()
13872 .find(|c| c.id == cid)
13873 .map(|c| c.display_name.clone())
13874 .unwrap_or_else(|| "container".into());
13875 let added = self
13876 .state
13877 .worker_route_editor
13878 .as_mut()
13879 .is_some_and(|ed| ed.append_deposit_at(&cid));
13880 if added {
13881 self.state
13882 .push_log(format!("Route: + deposit at {name} ({cid})"));
13883 } else {
13884 self.state.push_log(format!(
13885 "Route: {name} already in route — selected it (d to remove)"
13886 ));
13887 }
13888 }
13889 Some(Target::Npc(npc_id, label)) => {
13890 let template = self.re_template_candidates().into_iter().next();
13893 let Some(template) = template else {
13894 self.state.push_log(
13895 "Route: no items in your storage to sell — stock a chest first".to_string(),
13896 );
13897 return;
13898 };
13899 let added = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
13900 ed.append_trade_with(template.clone(), Some(npc_id.clone()), true)
13901 });
13902 if added {
13903 self.state
13904 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
13905 } else {
13906 self.state.push_log(format!(
13907 "Route: {label} already sells {template} — selected it (d to remove)"
13908 ));
13909 }
13910 }
13911 Some(Target::Node(id, label)) => {
13912 let added = self
13913 .state
13914 .worker_route_editor
13915 .as_mut()
13916 .is_some_and(|ed| ed.append_harvest_node(&id));
13917 if added {
13918 self.state
13919 .push_log(format!("Route: + harvest node {label}"));
13920 } else {
13921 self.state.push_log(format!(
13922 "Route: {label} already in route — selected it (d to remove)"
13923 ));
13924 }
13925 }
13926 None => {}
13927 }
13928 }
13929
13930 pub fn worker_route_editor_select(&mut self, delta: i32) {
13931 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13932 return;
13933 };
13934 if ed.stops.is_empty() {
13935 return;
13936 }
13937 let n = ed.stops.len() as i32;
13938 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
13939 ed.selected_stop_index = next;
13940 }
13941
13942 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
13943 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13944 return;
13945 };
13946 if delta < 0 {
13947 ed.move_selected_up();
13948 } else if delta > 0 {
13949 ed.move_selected_down();
13950 }
13951 }
13952
13953 pub fn worker_route_editor_delete_selected(&mut self) {
13954 let removed = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
13955 let before = ed.stop_count();
13956 ed.remove_selected_stop();
13957 ed.stop_count() < before
13958 });
13959 if removed {
13960 self.state.push_log("Route: removed selected stop");
13961 }
13962 }
13963
13964 pub fn worker_route_editor_clear_stops(&mut self) {
13967 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13968 return;
13969 };
13970 if ed.stops.is_empty() {
13971 self.state
13972 .push_log("Route: already empty — s saves an idle worker".to_string());
13973 return;
13974 }
13975 ed.stops.clear();
13976 ed.selected_stop_index = 0;
13977 self.state.push_log(
13978 "Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string(),
13979 );
13980 }
13981
13982 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
13983 if self.state.pending_worker_job_ack.is_some() {
13984 anyhow::bail!("route save still pending — wait for server ack");
13985 }
13986 let Some(ed) = self.state.worker_route_editor.clone() else {
13987 anyhow::bail!("route editor not open");
13988 };
13989 let (job_yaml, idle) = if ed.stops.is_empty() {
13992 (ed.build_idle_job_yaml(), true)
13993 } else {
13994 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
13995 };
13996 let worker_id = ed.worker_instance_id.clone();
13997 let route_view = if idle { None } else { Some(ed.to_route_view()) };
13998 let mode = if idle {
13999 flatland_protocol::WorkerModeView::Idle
14000 } else {
14001 flatland_protocol::WorkerModeView::JobLoop
14002 };
14003 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
14004 .state
14005 .hired_workers
14006 .iter()
14007 .find(|w| w.instance_id == worker_id)
14008 .map(|w| {
14009 (
14010 w.route.clone(),
14011 w.mode,
14012 w.step_label.clone(),
14013 w.last_error.clone(),
14014 )
14015 })
14016 .unwrap_or((
14017 None,
14018 flatland_protocol::WorkerModeView::Idle,
14019 String::new(),
14020 None,
14021 ));
14022 self.seq += 1;
14023 let seq = self.seq;
14024 self.session
14025 .submit_intent(Intent::SetWorkerJob {
14026 entity_id: self.state.entity_id,
14027 worker_instance_id: worker_id.clone(),
14028 job_yaml,
14029 seq,
14030 })
14031 .await?;
14032 self.state.intents_sent += 1;
14033 if let Some(w) = self
14034 .state
14035 .hired_workers
14036 .iter_mut()
14037 .find(|w| w.instance_id == worker_id)
14038 {
14039 w.route = route_view;
14040 w.mode = mode;
14041 w.last_error = None;
14042 if idle {
14043 w.step_label.clear();
14044 w.route_stop_index = None;
14045 }
14046 }
14047 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
14048 seq,
14049 worker_instance_id: worker_id,
14050 worker_label: ed.worker_label.clone(),
14051 idle,
14052 stop_count: ed.stops.len(),
14053 prev_route,
14054 prev_mode,
14055 prev_step_label,
14056 prev_last_error,
14057 });
14058 self.state.push_log(format!(
14059 "Route: saving for {}… (waiting for server)",
14060 ed.worker_label
14061 ));
14062 Ok(())
14064 }
14065 pub fn quest_menu_move(&mut self, delta: i32) {
14066 let n = self.state.active_quest_entries().len();
14067 if n == 0 {
14068 return;
14069 }
14070 let idx = self.state.quest_menu_index as i32;
14071 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
14072 }
14073
14074 pub fn quest_menu_page(&mut self, pages: i32) {
14075 let n = self.state.active_quest_entries().len();
14076 self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
14077 }
14078
14079 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
14080 let Some(offer) = self.state.selected_quest_offer().cloned() else {
14081 anyhow::bail!("no quest offer");
14082 };
14083 self.seq += 1;
14084 let seq = self.seq;
14085 self.session
14086 .submit_intent(Intent::AcceptQuest {
14087 entity_id: self.state.entity_id,
14088 quest_id: offer.quest_id,
14089 seq,
14090 })
14091 .await?;
14092 self.state.intents_sent += 1;
14093 Ok(())
14094 }
14095
14096 pub fn quest_offer_move(&mut self, delta: i32) {
14097 self.state.move_quest_offer_selection(delta);
14098 }
14099
14100 pub fn quest_offer_decline(&mut self) {
14101 self.state.clear_quest_offers();
14102 if !self.state.show_npc_chat
14103 && !self.state.show_shop_menu
14104 && self.state.npc_verb_target.is_some()
14105 {
14106 self.state.show_npc_verb_menu = true;
14107 }
14108 }
14109
14110 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
14111 if !self.state.show_quest_menu {
14112 return Ok(());
14113 }
14114 let active: Vec<_> = self
14115 .state
14116 .active_quest_entries()
14117 .into_iter()
14118 .cloned()
14119 .collect();
14120 let Some(entry) = active.get(self.state.quest_menu_index) else {
14121 return Ok(());
14122 };
14123 if self.state.quest_withdraw_confirm {
14124 if !entry.can_withdraw {
14125 anyhow::bail!("quest cannot be withdrawn");
14126 }
14127 self.seq += 1;
14128 let seq = self.seq;
14129 self.session
14130 .submit_intent(Intent::WithdrawQuest {
14131 entity_id: self.state.entity_id,
14132 quest_id: entry.quest_id.clone(),
14133 seq,
14134 })
14135 .await?;
14136 self.state.intents_sent += 1;
14137 self.state.quest_withdraw_confirm = false;
14138 return Ok(());
14139 }
14140 self.seq += 1;
14141 let seq = self.seq;
14142 self.session
14143 .submit_intent(Intent::TrackQuest {
14144 entity_id: self.state.entity_id,
14145 quest_id: entry.quest_id.clone(),
14146 seq,
14147 })
14148 .await?;
14149 self.state.intents_sent += 1;
14150 Ok(())
14151 }
14152
14153 pub fn quest_request_withdraw(&mut self) {
14154 if self.state.show_quest_menu {
14155 self.state.quest_withdraw_confirm = true;
14156 }
14157 }
14158
14159 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
14160 if !self.state.is_alive() {
14161 anyhow::bail!("you are dead");
14162 }
14163 let Some(catalog) = self.state.shop_catalog.clone() else {
14164 anyhow::bail!("no shop open");
14165 };
14166 self.seq += 1;
14167 let seq = self.seq;
14168 match self.state.shop_tab {
14169 ShopTab::Buy => {
14170 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
14171 anyhow::bail!("nothing selected");
14172 };
14173 if offer.already_owned {
14174 anyhow::bail!("already owned");
14175 }
14176 self.session
14177 .submit_intent(Intent::ShopBuy {
14178 entity_id: self.state.entity_id,
14179 npc_id: catalog.npc_id.clone(),
14180 offer_id: offer.offer_id.clone(),
14181 quantity: self.state.shop_quantity,
14182 seq,
14183 })
14184 .await?;
14185 }
14186 ShopTab::Sell => {
14187 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
14188 anyhow::bail!("nothing to sell");
14189 };
14190 if line.quantity == 0 {
14191 anyhow::bail!("you have no {}", line.label);
14192 }
14193 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
14194 self.session
14195 .submit_intent(Intent::ShopSell {
14196 entity_id: self.state.entity_id,
14197 npc_id: catalog.npc_id.clone(),
14198 template_id: line.template_id.clone(),
14199 quantity,
14200 seq,
14201 })
14202 .await?;
14203 }
14204 }
14205 self.state.intents_sent += 1;
14206 Ok(())
14207 }
14208
14209 pub fn craft_menu_move(&mut self, delta: i32) {
14210 let n = self.state.craft_filtered_indices().len();
14211 if n == 0 {
14212 return;
14213 }
14214 let idx = self.state.craft_menu_index as i32;
14215 let next = (idx + delta).rem_euclid(n as i32);
14216 self.state.craft_menu_index = next as usize;
14217 self.state.clamp_craft_batch_quantity();
14218 }
14219
14220 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
14221 self.state.craft_batch_adjust_quantity(delta);
14222 }
14223
14224 pub fn craft_batch_set_max(&mut self) {
14225 self.state.craft_batch_set_max();
14226 }
14227
14228 pub fn craft_batch_set_min(&mut self) {
14229 self.state.craft_batch_set_min();
14230 }
14231
14232 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
14233 let Some(blueprint) = self.state.craft_selected_blueprint().cloned() else {
14234 anyhow::bail!("no blueprints in this tab");
14235 };
14236 if !self.state.can_craft_blueprint(&blueprint) {
14237 let hint = self
14238 .state
14239 .craft_missing_hint(&blueprint)
14240 .unwrap_or_else(|| "missing materials".into());
14241 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
14242 }
14243 let count = self.state.craft_batch_quantity;
14244 let max = self.state.max_craft_batches(&blueprint);
14245 if max == 0 {
14246 anyhow::bail!("cannot craft {}", blueprint.label);
14247 }
14248 let batches = count.min(max);
14249 self.craft(&blueprint.id, Some(batches)).await?;
14250 Ok(())
14252 }
14253
14254 pub async fn move_by(
14255 &mut self,
14256 forward: f32,
14257 strafe: f32,
14258 vertical: f32,
14259 sprint: bool,
14260 sneak: bool,
14261 ) -> anyhow::Result<()> {
14262 if !self.state.is_alive() {
14263 anyhow::bail!("you are dead");
14264 }
14265 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
14266 self.last_move_forward = forward;
14267 self.last_move_strafe = strafe;
14268 }
14269 self.seq += 1;
14270 self.session
14271 .submit_intent(Intent::Move {
14272 entity_id: self.state.entity_id,
14273 forward,
14274 strafe,
14275 vertical,
14276 sprint: sprint && !sneak,
14277 sneak,
14278 seq: self.seq,
14279 })
14280 .await?;
14281 self.state.intents_sent += 1;
14282 Ok(())
14283 }
14284
14285 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
14286 if !self.state.connected {
14287 crate::harvest_trace!("harvest_nearest rejected: not connected");
14288 anyhow::bail!("not connected");
14289 }
14290 if !self.state.is_alive() {
14291 crate::harvest_trace!("harvest_nearest rejected: player dead");
14292 anyhow::bail!("you are dead");
14293 }
14294 if self.state.harvest_in_progress {
14295 if self.state.harvest_state_stale() {
14296 self.state.clear_harvest_state();
14297 } else {
14298 anyhow::bail!("already harvesting");
14299 }
14300 }
14301 let (px, py) = self
14302 .state
14303 .player
14304 .as_ref()
14305 .map(|p| (p.transform.position.x, p.transform.position.y))
14306 .unwrap_or((0.0, 0.0));
14307
14308 let available = self
14309 .state
14310 .resource_nodes
14311 .iter()
14312 .filter(|n| !n.harvest_off)
14313 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14314 .count();
14315 let node_id = self
14316 .state
14317 .resource_nodes
14318 .iter()
14319 .filter(|n| !n.harvest_off)
14320 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14321 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
14322 .min_by(|a, b| {
14323 let da = distance(px, py, a.x, a.y);
14324 let db = distance(px, py, b.x, b.y);
14325 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
14326 })
14327 .map(|n| n.id.clone());
14328
14329 let Some(node_id) = node_id else {
14330 let has_loot = self
14331 .state
14332 .ground_drops
14333 .iter()
14334 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14335 if has_loot {
14336 return self.pickup_nearest().await;
14337 }
14338 anyhow::bail!(
14339 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
14340 );
14341 };
14342
14343 self.seq += 1;
14344 let seq = self.seq;
14345 crate::harvest_trace!(
14346 entity_id = self.state.entity_id,
14347 node_id = %node_id,
14348 seq,
14349 px,
14350 py,
14351 available_nodes = available,
14352 "submitting harvest intent"
14353 );
14354 self.session
14355 .submit_intent(Intent::Harvest {
14356 entity_id: self.state.entity_id,
14357 node_id,
14358 seq,
14359 })
14360 .await?;
14361 self.state.intents_sent += 1;
14362 self.state.harvest_in_progress = true;
14363 self.state.harvest_started_at = Some(Instant::now());
14364 self.state.push_log("Harvesting…");
14365 crate::harvest_trace!(
14366 entity_id = self.state.entity_id,
14367 seq,
14368 "harvest intent queued to session"
14369 );
14370 Ok(())
14371 }
14372
14373 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
14374 if !self.state.is_alive() {
14375 anyhow::bail!("you are dead");
14376 }
14377 let blueprint_id = self
14378 .state
14379 .blueprints
14380 .iter()
14381 .find(|bp| self.state.can_craft_blueprint(bp))
14382 .map(|bp| bp.id.clone())
14383 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
14384 self.craft(&blueprint_id, None).await
14385 }
14386
14387 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
14388 if !self.state.is_alive() {
14389 anyhow::bail!("you are dead");
14390 }
14391 self.seq += 1;
14392 self.session
14393 .submit_intent(Intent::Craft {
14394 entity_id: self.state.entity_id,
14395 blueprint_id: blueprint_id.to_string(),
14396 count,
14397 seq: self.seq,
14398 })
14399 .await?;
14400 self.state.intents_sent += 1;
14401 let (label, batches) = self
14402 .state
14403 .blueprints
14404 .iter()
14405 .find(|b| b.id == blueprint_id)
14406 .map(|b| {
14407 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
14408 (b.label.as_str(), n)
14409 })
14410 .unwrap_or((blueprint_id, count.unwrap_or(1)));
14411 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
14412 self.state.craft_channel_blueprint_id = Some(blueprint_id.to_string());
14413 Ok(())
14414 }
14415
14416 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
14417 if !self.state.is_alive() {
14418 anyhow::bail!("you are dead");
14419 }
14420 let target_id = match self.state.nearest_interact_target() {
14421 Some(id) => id,
14422 None => {
14423 anyhow::bail!("nothing to interact with nearby");
14424 }
14425 };
14426 if self.state.npcs.iter().any(|n| n.id == target_id) {
14427 self.state.show_npc_verb_menu = true;
14428 self.state.npc_verb_target = Some(target_id);
14429 self.state.npc_verb_index = 0;
14430 self.state.npc_verb_notice = None;
14431 return Ok(());
14432 }
14433 if self
14434 .state
14435 .hired_workers
14436 .iter()
14437 .any(|w| w.instance_id == target_id)
14438 {
14439 return self.open_workers_menu_for(&target_id).await;
14440 }
14441 if let Ok(peer_id) = target_id.parse::<EntityId>() {
14442 if self
14443 .state
14444 .hired_workers
14445 .iter()
14446 .any(|w| w.entity_id == peer_id)
14447 {
14448 if let Some(w) = self
14449 .state
14450 .hired_workers
14451 .iter()
14452 .find(|w| w.entity_id == peer_id)
14453 {
14454 let id = w.instance_id.clone();
14455 return self.open_workers_menu_for(&id).await;
14456 }
14457 }
14458 if let Some(entity) = self
14459 .state
14460 .entities
14461 .iter()
14462 .find(|e| e.id == peer_id && e.id != self.state.entity_id)
14463 {
14464 self.state.player_verbs.open_for(peer_id, &entity.label);
14465 return Ok(());
14466 }
14467 }
14468 self.seq += 1;
14469 self.session
14470 .submit_intent(Intent::Interact {
14471 entity_id: self.state.entity_id,
14472 target_id: target_id.clone(),
14473 seq: self.seq,
14474 })
14475 .await?;
14476 self.state.intents_sent += 1;
14477 Ok(())
14478 }
14479
14480 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
14485 if !self.state.is_alive() {
14486 anyhow::bail!("you are dead");
14487 }
14488 let (px, py) = self.state.player_position();
14489 let has_loot = self
14490 .state
14491 .ground_drops
14492 .iter()
14493 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14494 if has_loot {
14495 return self.pickup_nearest().await;
14496 }
14497
14498 if let Some(primary) = self.state.probe_use_world().primary {
14500 match primary.kind.cascade_stage() {
14501 0 => return self.interact_nearest().await,
14502 2 => return self.pickup_nearest_container().await,
14503 3 => return self.harvest_nearest().await,
14504 _ => {}
14505 }
14506 }
14507
14508 if let Some(plot) = self.state.my_plot_under_player().cloned() {
14509 const SELL_WINDOW: Duration = Duration::from_millis(1200);
14511 let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
14512 && self
14513 .state
14514 .sell_plot_armed_at
14515 .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
14516 if sell_armed {
14517 return self.confirm_sell_plot_to_crown(plot.plot_id).await;
14518 }
14519 self.state.sell_plot_confirm = None;
14520 self.state.sell_plot_armed_at = None;
14521
14522 let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
14525 self.state.npcs.iter().any(|n| n.id == id)
14526 || self.state.hired_workers.iter().any(|w| w.instance_id == id)
14527 || self.state.doors.iter().any(|d| d.id == id)
14528 || self.state.interactables.iter().any(|i| {
14529 i.id == id
14530 && matches!(i.kind.as_str(), "quest_board" | "well" | "exit" | "enter")
14531 })
14532 || id.parse::<EntityId>().is_ok_and(|eid| {
14533 self.state
14534 .entities
14535 .iter()
14536 .any(|e| e.id == eid && e.id != self.state.entity_id)
14537 })
14538 });
14539 if !blocking_interact {
14540 match self.harvest_nearest().await {
14542 Ok(()) => return Ok(()),
14543 Err(err) => {
14544 let msg = err.to_string();
14545 if !(msg.contains("no harvestable")
14546 || msg.contains("press p")
14547 || msg.contains("press f")
14548 || msg.contains("nothing"))
14549 {
14550 return Err(err);
14551 }
14552 }
14553 }
14554 return Ok(());
14555 }
14556 }
14557 if self.state.nearest_interact_target().is_some() {
14558 return self.interact_nearest().await;
14559 }
14560 if let Some((label, dist)) = self.state.nearest_quest_board() {
14563 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
14564 anyhow::bail!(
14565 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
14566 );
14567 }
14568 }
14569
14570 match self.harvest_nearest().await {
14571 Ok(()) => Ok(()),
14572 Err(err) => {
14573 let msg = err.to_string();
14574 if msg.contains("no harvestable")
14575 || msg.contains("press p")
14576 || msg.contains("press f")
14577 {
14578 anyhow::bail!(
14579 "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
14580 );
14581 }
14582 Err(err)
14583 }
14584 }
14585 }
14586
14587 pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
14589 if !self.state.is_alive() {
14590 anyhow::bail!("you are dead");
14591 }
14592 if self.state.claim_mode.is_some() {
14593 anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
14594 }
14595 let zone = self
14596 .state
14597 .free_property_zone_under_player()
14598 .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
14599 let zone_id = zone.id.clone();
14600 let label = zone
14601 .label
14602 .as_deref()
14603 .filter(|s| !s.trim().is_empty())
14604 .unwrap_or(zone.id.as_str())
14605 .to_string();
14606 self.enter_claim_mode(&zone_id);
14607 self.state.push_log(format!(
14608 "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
14609 ));
14610 Ok(())
14611 }
14612
14613 pub fn enter_claim_mode(&mut self, zone_id: &str) {
14615 let Some(zone) = self
14616 .state
14617 .property_zones
14618 .iter()
14619 .find(|z| z.id == zone_id)
14620 .cloned()
14621 else {
14622 self.state.push_log("unknown property zone");
14623 return;
14624 };
14625 self.state.sell_plot_confirm = None;
14626 self.state.sell_plot_armed_at = None;
14627 let min_area = self
14628 .state
14629 .property_plot_settings
14630 .as_ref()
14631 .map(|s| s.min_plot_area_m2)
14632 .unwrap_or(4.0)
14633 .max(1.0);
14634 let min_side = min_area.sqrt().ceil().max(1.0) as u32;
14635 let side = 4u32.max(min_side);
14636 let (px, py) = self.state.player_position();
14637 let anchor_x = px.floor();
14638 let anchor_y = py.floor();
14639 self.state.claim_mode = Some(ClaimModeState {
14640 zone_id: zone.id.clone(),
14641 width_m: side,
14642 height_m: side,
14643 anchor_x,
14644 anchor_y,
14645 });
14646 let label = zone
14647 .label
14648 .as_deref()
14649 .filter(|s| !s.trim().is_empty())
14650 .unwrap_or(zone.id.as_str());
14651 self.state.push_log(format!(
14652 "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
14653 ));
14654 }
14655
14656 pub fn cancel_claim_mode(&mut self) {
14657 if self.state.claim_mode.take().is_some() {
14658 self.state.push_log("Claim cancelled");
14659 }
14660 }
14661
14662 pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
14664 if !self.state.is_alive() {
14665 anyhow::bail!("you are dead");
14666 }
14667 if self.state.relocate_mode.is_some() {
14668 anyhow::bail!("already relocating — Enter confirm, Esc cancel");
14669 }
14670 if self.state.claim_mode.is_some() {
14671 anyhow::bail!("finish or cancel claim mode first");
14672 }
14673 let chest = self
14674 .state
14675 .placed_containers
14676 .iter()
14677 .find(|c| c.id == container_id)
14678 .cloned()
14679 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
14680 let (px, py) = self.state.player_position();
14681 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
14682 anyhow::bail!("too far from {}", chest.display_name);
14683 }
14684 if chest.locked && !chest.accessible {
14685 anyhow::bail!(
14686 "need the matching key for {} before moving it",
14687 chest.display_name
14688 );
14689 }
14690 let label = if chest.display_name.trim().is_empty() {
14691 chest.template_id.clone()
14692 } else {
14693 chest.display_name.clone()
14694 };
14695 self.state.relocate_mode = Some(RelocateModeState {
14696 container_id: chest.id.clone(),
14697 label: label.clone(),
14698 cursor_x: chest.x.floor() + 0.5,
14699 cursor_y: chest.y.floor() + 0.5,
14700 });
14701 self.state.push_log(format!(
14702 "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
14703 ));
14704 Ok(())
14705 }
14706
14707 pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
14709 let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
14710 anyhow::bail!("no chest nearby to relocate");
14711 };
14712 if chest.locked && !chest.accessible {
14713 anyhow::bail!(
14714 "need the matching key for {} before moving it",
14715 chest.display_name
14716 );
14717 }
14718 self.begin_relocate_container(&chest.id)
14721 }
14722
14723 pub fn cancel_relocate_mode(&mut self) {
14724 if self.state.relocate_mode.take().is_some() {
14725 self.state.push_log("Relocate cancelled");
14726 }
14727 }
14728
14729 pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
14730 let Some(mode) = self.state.relocate_mode.as_mut() else {
14731 return;
14732 };
14733 let max_x = self.state.world_width_m.max(1.0);
14734 let max_y = self.state.world_height_m.max(1.0);
14735 let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
14736 let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
14737 mode.cursor_x = nx.floor() + 0.5;
14738 mode.cursor_y = ny.floor() + 0.5;
14739 }
14740
14741 pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
14742 let Some(mode) = self.state.relocate_mode.as_mut() else {
14743 return;
14744 };
14745 let max_x = self.state.world_width_m.max(1.0);
14746 let max_y = self.state.world_height_m.max(1.0);
14747 mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
14748 mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
14749 }
14750
14751 pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
14752 if !self.state.is_alive() {
14753 anyhow::bail!("you are dead");
14754 }
14755 let Some(mode) = self.state.relocate_mode.clone() else {
14756 anyhow::bail!("not relocating");
14757 };
14758 let (px, py) = self.state.player_position();
14759 let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
14760 if dist > 8.0 {
14761 anyhow::bail!("destination too far (max 8 m)");
14762 }
14763 self.seq += 1;
14764 self.session
14765 .submit_intent(Intent::MovePlacedContainer {
14766 entity_id: self.state.entity_id,
14767 container_id: mode.container_id.clone(),
14768 x: mode.cursor_x,
14769 y: mode.cursor_y,
14770 seq: self.seq,
14771 })
14772 .await?;
14773 self.state.intents_sent += 1;
14774 self.state.relocate_mode = None;
14775 self.state.push_log(format!("Moving {}…", mode.label));
14776 Ok(())
14777 }
14778
14779 pub fn claim_set_preset(&mut self, w: u32, h: u32) {
14780 let Some(mode) = self.state.claim_mode.as_mut() else {
14781 return;
14782 };
14783 mode.width_m = w.max(1);
14784 mode.height_m = h.max(1);
14785 }
14786
14787 pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
14788 let Some(mode) = self.state.claim_mode.as_mut() else {
14789 return;
14790 };
14791 let w = (mode.width_m as i32 + dw).max(1) as u32;
14792 let h = (mode.height_m as i32 + dh).max(1) as u32;
14793 mode.width_m = w;
14794 mode.height_m = h;
14795 }
14796
14797 pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
14799 let Some(mode) = self.state.claim_mode.as_mut() else {
14800 return;
14801 };
14802 let max_x = self.state.world_width_m.max(1.0);
14803 let max_y = self.state.world_height_m.max(1.0);
14804 let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
14805 let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
14806 mode.anchor_x = nx.floor();
14807 mode.anchor_y = ny.floor();
14808 }
14809
14810 pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
14811 if !self.state.is_alive() {
14812 anyhow::bail!("you are dead");
14813 }
14814 let Some(mode) = self.state.claim_mode.clone() else {
14815 anyhow::bail!("not in claim mode");
14816 };
14817 let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
14818 self.state.claim_quote()
14819 else {
14820 anyhow::bail!("cannot quote claim");
14821 };
14822 if !valid {
14823 anyhow::bail!(reason);
14824 }
14825 if !can_afford {
14826 anyhow::bail!(
14827 "not enough copper (need {})",
14828 crate::currency::format_copper(purchase)
14829 );
14830 }
14831 let (x0, y0, x1, y1) = self
14832 .state
14833 .claim_footprint_rect()
14834 .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
14835 let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
14836 self.seq += 1;
14837 self.session
14838 .submit_intent(Intent::BuyPlot {
14839 entity_id: self.state.entity_id,
14840 zone_id: mode.zone_id,
14841 x0,
14842 y0,
14843 x1,
14844 y1,
14845 seq: self.seq,
14846 })
14847 .await?;
14848 self.state.intents_sent += 1;
14849 self.state.claim_mode = None;
14850 self.state.push_log(format!(
14851 "Buying plot for {}",
14852 crate::currency::format_copper(purchase)
14853 ));
14854 Ok(())
14855 }
14856
14857 pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
14858 if !self.state.is_alive() {
14859 anyhow::bail!("you are dead");
14860 }
14861 let zone_id = self
14862 .state
14863 .claim_mode
14864 .as_ref()
14865 .map(|m| m.zone_id.clone())
14866 .or_else(|| {
14867 self.state
14868 .free_property_zone_under_player()
14869 .map(|z| z.id.clone())
14870 })
14871 .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
14872 self.seq += 1;
14873 self.session
14874 .submit_intent(Intent::BuyPlotAllFree {
14875 entity_id: self.state.entity_id,
14876 zone_id,
14877 seq: self.seq,
14878 })
14879 .await?;
14880 self.state.intents_sent += 1;
14881 self.state.claim_mode = None;
14882 self.state.push_log("Claiming largest free plot…");
14883 Ok(())
14884 }
14885
14886 pub async fn confirm_sell_plot_to_crown(&mut self, plot_id: uuid::Uuid) -> anyhow::Result<()> {
14887 if !self.state.is_alive() {
14888 anyhow::bail!("you are dead");
14889 }
14890 self.seq += 1;
14891 self.session
14892 .submit_intent(Intent::SellPlotToCrown {
14893 entity_id: self.state.entity_id,
14894 plot_id,
14895 seq: self.seq,
14896 })
14897 .await?;
14898 self.state.intents_sent += 1;
14899 self.state.sell_plot_confirm = None;
14900 self.state.sell_plot_armed_at = None;
14901 self.state.push_log("Selling plot to the crown…");
14902 Ok(())
14903 }
14904
14905 pub async fn set_plot_farm_public(
14906 &mut self,
14907 plot_id: uuid::Uuid,
14908 public: bool,
14909 public_tax_discount_bps: u32,
14910 ) -> anyhow::Result<()> {
14911 self.seq += 1;
14912 self.session
14913 .submit_intent(Intent::SetPlotFarmPublic {
14914 entity_id: self.state.entity_id,
14915 plot_id,
14916 public,
14917 public_tax_discount_bps,
14918 seq: self.seq,
14919 })
14920 .await?;
14921 self.state.intents_sent += 1;
14922 Ok(())
14923 }
14924
14925 pub async fn plot_farm_allow_upsert(
14926 &mut self,
14927 plot_id: uuid::Uuid,
14928 character_id: Option<uuid::Uuid>,
14929 character_name: String,
14930 tax_discount_bps: u32,
14931 ) -> anyhow::Result<()> {
14932 self.seq += 1;
14933 self.session
14934 .submit_intent(Intent::PlotFarmAllowUpsert {
14935 entity_id: self.state.entity_id,
14936 plot_id,
14937 character_id,
14938 character_name,
14939 tax_discount_bps,
14940 seq: self.seq,
14941 })
14942 .await?;
14943 self.state.intents_sent += 1;
14944 Ok(())
14945 }
14946
14947 pub async fn plot_farm_allow_remove(
14948 &mut self,
14949 plot_id: uuid::Uuid,
14950 character_id: uuid::Uuid,
14951 ) -> anyhow::Result<()> {
14952 self.seq += 1;
14953 self.session
14954 .submit_intent(Intent::PlotFarmAllowRemove {
14955 entity_id: self.state.entity_id,
14956 plot_id,
14957 character_id,
14958 seq: self.seq,
14959 })
14960 .await?;
14961 self.state.intents_sent += 1;
14962 Ok(())
14963 }
14964
14965 pub fn open_farm_access_panel(&mut self) {
14966 let Some(plot) = self.state.my_plot_under_player() else {
14967 self.state
14968 .push_log("Stand on your deed plot to manage farm access");
14969 return;
14970 };
14971 self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
14972 self.state.farm_access_index = 0;
14973 self.state.show_farm_access = true;
14974 }
14975
14976 pub fn close_farm_access_panel(&mut self) {
14977 self.state.show_farm_access = false;
14978 self.state.farm_access_name_draft.clear();
14979 self.state.farm_access_index = 0;
14980 }
14981
14982 pub fn farm_access_move(&mut self, delta: i32) {
14983 let n = self.farm_access_row_count().max(1);
14984 let idx = self.state.farm_access_index as i32 + delta;
14985 self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
14986 }
14987
14988 pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
14989 let Some(plot) = self.state.my_plot_under_player() else {
14990 return vec![FarmAccessRow::PublicToggle];
14991 };
14992 let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
14993 for g in &plot.farm_allow {
14994 rows.push(FarmAccessRow::AllowRemove {
14995 character_id: g.character_id,
14996 label: if g.character_label.trim().is_empty() {
14997 g.character_id.to_string()[..8].to_string()
14998 } else {
14999 g.character_label.clone()
15000 },
15001 tax_discount_bps: g.tax_discount_bps,
15002 });
15003 }
15004 for e in &self.state.entities {
15005 if e.id == self.state.entity_id || e.label.trim().is_empty() {
15006 continue;
15007 }
15008 if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
15009 continue;
15010 }
15011 if self
15012 .state
15013 .npcs
15014 .iter()
15015 .any(|n| n.id == e.label || n.label == e.label)
15016 {
15017 continue;
15018 }
15019 if plot
15020 .farm_allow
15021 .iter()
15022 .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
15023 {
15024 continue;
15025 }
15026 rows.push(FarmAccessRow::NearbyAdd {
15027 name: e.label.clone(),
15028 });
15029 }
15030 rows
15031 }
15032
15033 pub fn farm_access_row_count(&self) -> usize {
15034 self.farm_access_rows().len().max(1)
15035 }
15036
15037 pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
15038 let Some(plot) = self.state.my_plot_under_player().cloned() else {
15039 self.close_farm_access_panel();
15040 return Ok(());
15041 };
15042 let rows = self.farm_access_rows();
15043 let Some(row) = rows.get(self.state.farm_access_index) else {
15044 return Ok(());
15045 };
15046 match row {
15047 FarmAccessRow::PublicToggle => {
15048 self.set_plot_farm_public(
15049 plot.plot_id,
15050 !plot.farm_public,
15051 plot.public_tax_discount_bps,
15052 )
15053 .await
15054 }
15055 FarmAccessRow::PublicDiscount => Ok(()),
15056 FarmAccessRow::AllowRemove { character_id, .. } => {
15057 self.plot_farm_allow_remove(plot.plot_id, *character_id)
15058 .await
15059 }
15060 FarmAccessRow::NearbyAdd { name } => {
15061 let disc = self
15062 .state
15063 .farm_access_discount_bps
15064 .max(plot.public_tax_discount_bps);
15065 self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
15066 .await
15067 }
15068 }
15069 }
15070
15071 pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
15072 let Some(plot) = self.state.my_plot_under_player().cloned() else {
15073 return Ok(());
15074 };
15075 let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
15076 self.state.farm_access_discount_bps = next;
15077 self.state.farm_access_index = 1;
15078 self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
15079 .await
15080 }
15081
15082 pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
15084 if self.state.farmable_plot_under_player().is_none() {
15085 anyhow::bail!("stand on a farmable plot to cultivate");
15086 }
15087 let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
15088 let (px, py) = self.state.player_position();
15089 if self
15090 .state
15091 .terrain_at(px, py)
15092 .is_some_and(|k| k == TerrainKindView::Tilled)
15093 {
15094 anyhow::bail!("already tilled — stand on bare soil and press c");
15095 }
15096 anyhow::bail!("cannot till this cell — move onto soil on your plot");
15097 };
15098 self.cultivate_at(tx, ty).await
15099 }
15100
15101 pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
15103 if self.state.farmable_plot_under_player().is_none() {
15104 anyhow::bail!("stand on a farmable plot to plant");
15105 }
15106 if !self.state.underfoot_free_tilled_plant_slot() {
15107 anyhow::bail!("stand on empty tilled soil and press p");
15108 }
15109 let seeds = self.state.farm_seed_entries();
15110 if seeds.is_empty() {
15111 anyhow::bail!("no seeds in inventory — buy seeds from Eli");
15112 }
15113 if seeds.len() == 1 {
15114 return self.plant_seeds(seeds[0].0.clone(), 1).await;
15115 }
15116 self.open_plant_menu();
15117 Ok(())
15118 }
15119
15120 pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
15122 let Some(plot) = self.state.my_plot_under_player() else {
15123 anyhow::bail!("stand on your plot to build");
15124 };
15125 if plot.building_id.is_some() {
15126 anyhow::bail!("this plot already has a building");
15127 }
15128 let building_now = self
15129 .state
15130 .timed_channel
15131 .as_ref()
15132 .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
15133 if !building_now && self.state.building_materials.is_empty() {
15134 anyhow::bail!("no building materials loaded — wait a moment and try again");
15135 }
15136 self.state.show_plot_build_menu = true;
15137 self.state.show_craft_menu = false;
15138 self.state.show_shop_menu = false;
15139 self.state.shop_catalog = None;
15140 self.state.show_stats = false;
15141 self.state.show_inventory_menu = false;
15142 self.state.plot_build_focus_wall = true;
15143 let walls = self.state.plot_build_wall_options().len();
15144 let roofs = self.state.plot_build_roof_options().len();
15145 if walls > 0 {
15146 self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
15147 } else {
15148 self.state.plot_build_wall_index = 0;
15149 }
15150 if roofs > 0 {
15151 self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
15152 } else {
15153 self.state.plot_build_roof_index = 0;
15154 }
15155 Ok(())
15156 }
15157
15158 pub fn close_plot_build_menu(&mut self) {
15159 self.state.show_plot_build_menu = false;
15160 }
15161
15162 pub fn plot_build_menu_move(&mut self, delta: i32) {
15163 let walls = self.state.plot_build_wall_options();
15164 let roofs = self.state.plot_build_roof_options();
15165 if self.state.plot_build_focus_wall {
15166 if walls.is_empty() {
15167 return;
15168 }
15169 let n = walls.len() as i32;
15170 let cur = self.state.plot_build_wall_index as i32;
15171 self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
15172 } else {
15173 if roofs.is_empty() {
15174 return;
15175 }
15176 let n = roofs.len() as i32;
15177 let cur = self.state.plot_build_roof_index as i32;
15178 self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
15179 }
15180 }
15181
15182 pub fn plot_build_menu_toggle_focus(&mut self) {
15183 self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
15184 }
15185
15186 pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
15188 let wall = self
15189 .state
15190 .plot_build_selected_wall()
15191 .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
15192 .id
15193 .clone();
15194 let roof = self
15195 .state
15196 .plot_build_selected_roof()
15197 .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
15198 .id
15199 .clone();
15200 self.start_plot_build(&wall, &roof).await
15202 }
15203
15204 pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
15206 self.seq += 1;
15207 self.session
15208 .submit_intent(Intent::CancelPlotBuild {
15209 entity_id: self.state.entity_id,
15210 seq: self.seq,
15211 })
15212 .await?;
15213 self.state.intents_sent += 1;
15214 Ok(())
15215 }
15216
15217 pub async fn start_plot_build(
15219 &mut self,
15220 wall_material_id: &str,
15221 roof_material_id: &str,
15222 ) -> anyhow::Result<()> {
15223 let Some(plot) = self.state.my_plot_under_player() else {
15224 anyhow::bail!("stand on your plot to build");
15225 };
15226 if plot.building_id.is_some() {
15227 anyhow::bail!("this plot already has a building");
15228 }
15229 let plot_id = plot.plot_id;
15230 self.seq += 1;
15231 self.session
15232 .submit_intent(Intent::StartPlotBuild {
15233 entity_id: self.state.entity_id,
15234 plot_id,
15235 wall_material_id: wall_material_id.to_string(),
15236 roof_material_id: roof_material_id.to_string(),
15237 seq: self.seq,
15238 })
15239 .await?;
15240 self.state.intents_sent += 1;
15241 Ok(())
15242 }
15243
15244 pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
15246 let (px, py) = self.state.player_position();
15247 let mut best: Option<(f32, String, bool)> = None;
15248 for d in &self.state.doors {
15249 if d.lock_id.is_none() {
15250 continue;
15251 }
15252 let dist = (d.x - px).hypot(d.y - py);
15253 if dist > DOOR_INTERACTION_RADIUS_M {
15254 continue;
15255 }
15256 if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
15257 best = Some((dist, d.id.clone(), d.locked));
15258 }
15259 }
15260 let Some((_, door_id, locked_now)) = best else {
15261 anyhow::bail!("no lockable door nearby");
15262 };
15263 let locked = !locked_now;
15264 self.seq += 1;
15265 self.session
15266 .submit_intent(Intent::SetDoorLocked {
15267 entity_id: self.state.entity_id,
15268 door_id,
15269 locked,
15270 seq: self.seq,
15271 })
15272 .await?;
15273 self.state.intents_sent += 1;
15274 Ok(())
15275 }
15276
15277 pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
15279 if !self.state.is_alive() {
15280 anyhow::bail!("you are dead");
15281 }
15282 if self.state.effective_inside_building().is_some() {
15283 anyhow::bail!("already inside");
15284 }
15285 let (px, py) = self.state.player_position();
15286 let mut best: Option<(f32, String)> = None;
15287 for d in &self.state.doors {
15288 if !d.open || d.locked {
15289 continue;
15290 }
15291 let player_house = self
15292 .state
15293 .buildings
15294 .iter()
15295 .find(|b| b.id == d.building_id)
15296 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15297 if !player_house {
15298 continue;
15299 }
15300 let dist = (d.x - px).hypot(d.y - py);
15301 if dist > DOOR_INTERACTION_RADIUS_M {
15302 continue;
15303 }
15304 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15305 best = Some((dist, d.id.clone()));
15306 }
15307 }
15308 let Some((_, door_id)) = best else {
15309 anyhow::bail!("no open house door nearby — open with f first");
15310 };
15311 self.seq += 1;
15312 self.session
15313 .submit_intent(Intent::EnterBuildingDoor {
15314 entity_id: self.state.entity_id,
15315 door_id,
15316 seq: self.seq,
15317 })
15318 .await?;
15319 self.state.intents_sent += 1;
15320 Ok(())
15321 }
15322
15323 pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
15326 if !self.state.is_alive() {
15327 anyhow::bail!("you are dead");
15328 }
15329 let Some(bid) = self.state.effective_inside_building() else {
15330 anyhow::bail!("not inside a building");
15331 };
15332 let (px, py) = self.state.player_position();
15333 let mut best: Option<(f32, String)> = None;
15334 for d in &self.state.doors {
15335 if d.building_id != bid || d.portal.is_none() {
15336 continue;
15337 }
15338 let player_house = self
15339 .state
15340 .buildings
15341 .iter()
15342 .find(|b| b.id == d.building_id)
15343 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15344 if !player_house {
15345 continue;
15346 }
15347 let dist = (d.x - px).hypot(d.y - py);
15348 if dist > 1.5 {
15349 continue;
15350 }
15351 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15352 best = Some((dist, d.id.clone()));
15353 }
15354 }
15355 let Some((_, door_id)) = best else {
15356 anyhow::bail!("stand by the door to exit");
15357 };
15358 self.seq += 1;
15359 self.session
15360 .submit_intent(Intent::ExitBuildingDoor {
15361 entity_id: self.state.entity_id,
15362 door_id,
15363 seq: self.seq,
15364 })
15365 .await?;
15366 self.state.intents_sent += 1;
15367 Ok(())
15368 }
15369
15370 pub async fn confirm_interior_edit(
15372 &mut self,
15373 building_id: String,
15374 rooms: Vec<flatland_protocol::InteriorRoomEdit>,
15375 room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
15376 ) -> anyhow::Result<()> {
15377 self.seq += 1;
15378 self.session
15379 .submit_intent(Intent::ConfirmInteriorEdit {
15380 entity_id: self.state.entity_id,
15381 building_id,
15382 rooms,
15383 room_doors,
15384 seq: self.seq,
15385 })
15386 .await?;
15387 self.state.intents_sent += 1;
15388 Ok(())
15389 }
15390
15391 pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
15392 if !self.state.is_alive() {
15393 anyhow::bail!("you are dead");
15394 }
15395 self.seq += 1;
15396 self.session
15397 .submit_intent(Intent::Cultivate {
15398 entity_id: self.state.entity_id,
15399 x,
15400 y,
15401 seq: self.seq,
15402 })
15403 .await?;
15404 self.state.intents_sent += 1;
15405 Ok(())
15406 }
15407
15408 pub async fn plant_seeds(
15409 &mut self,
15410 seed_template_id: String,
15411 quantity: u32,
15412 ) -> anyhow::Result<()> {
15413 if !self.state.is_alive() {
15414 anyhow::bail!("you are dead");
15415 }
15416 self.seq += 1;
15417 self.session
15418 .submit_intent(Intent::PlantSeeds {
15419 entity_id: self.state.entity_id,
15420 seed_template_id: seed_template_id.clone(),
15421 quantity,
15422 seq: self.seq,
15423 })
15424 .await?;
15425 self.state.intents_sent += 1;
15426 self.state
15427 .push_log(format!("Planting {quantity}× {seed_template_id}…"));
15428 Ok(())
15429 }
15430
15431 pub fn open_plant_menu(&mut self) {
15432 if self.state.farm_seed_entries().is_empty() {
15433 self.state.push_log("No seeds in inventory to plant");
15434 return;
15435 }
15436 self.state.show_plant_menu = true;
15437 self.state.plant_menu_index = 0;
15438 self.state.plant_quantity = 1;
15439 self.state.clamp_plant_menu();
15440 }
15441
15442 pub fn close_plant_menu(&mut self) {
15443 self.state.show_plant_menu = false;
15444 }
15445
15446 pub fn plant_menu_move(&mut self, delta: i32) {
15447 let n = self.state.farm_seed_entries().len();
15448 if n == 0 {
15449 return;
15450 }
15451 let idx = self.state.plant_menu_index as i32 + delta;
15452 self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
15453 self.state.clamp_plant_menu();
15454 }
15455
15456 pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
15457 let next = self.state.plant_quantity as i32 + delta;
15458 self.state.plant_quantity = next.max(1) as u32;
15459 self.state.clamp_plant_menu();
15460 }
15461
15462 pub fn plant_menu_set_quantity_max(&mut self) {
15463 if let Some((_, max, _)) = self.state.plant_menu_selection() {
15464 self.state.plant_quantity = max;
15465 }
15466 self.state.clamp_plant_menu();
15467 }
15468
15469 pub fn plant_menu_set_quantity_min(&mut self) {
15470 self.state.plant_quantity = 1;
15471 self.state.clamp_plant_menu();
15472 }
15473
15474 pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
15475 let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
15476 self.close_plant_menu();
15477 anyhow::bail!("no seeds to plant");
15478 };
15479 self.close_plant_menu();
15480 self.plant_seeds(seed, qty).await?;
15481 self.state.push_log(format!("Planted {qty}× {label}"));
15482 Ok(())
15483 }
15484
15485 pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
15488 if !self.state.is_alive() {
15489 anyhow::bail!("you are dead");
15490 }
15491 let binding = self
15492 .state
15493 .hotbar_ability(slot)
15494 .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
15495 .to_string();
15496 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
15497 let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
15498 if qty == 0 {
15499 anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
15500 }
15501 return self.use_item(template_id).await;
15502 }
15503 let ability_id = binding;
15504 if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
15505 return self
15506 .cast_ability(&ability_id, Some(self.state.entity_id))
15507 .await;
15508 }
15509 let is_heal = ability_id == "heal_touch"
15510 || self
15511 .state
15512 .ability_meta
15513 .get(&ability_id)
15514 .map(|meta| meta.is_heal)
15515 .unwrap_or(false);
15516 let target = if is_heal {
15517 Some(
15518 self.state
15519 .target_for_slot(2)
15520 .unwrap_or(self.state.entity_id),
15521 )
15522 } else {
15523 self.state
15524 .target_for_slot(1)
15525 .or_else(|| self.state.target_for_slot(2))
15526 };
15527 let Some(target_id) = target else {
15528 anyhow::bail!("no target — Tab to select, then press the hotbar key");
15529 };
15530 self.cast_ability(&ability_id, Some(target_id)).await
15531 }
15532
15533 pub async fn set_hotbar_slot(
15536 &mut self,
15537 slot: u8,
15538 ability_id: Option<&str>,
15539 ) -> anyhow::Result<()> {
15540 if !self.state.is_alive() {
15541 anyhow::bail!("you are dead");
15542 }
15543 if !(1..=9).contains(&slot) {
15544 anyhow::bail!("hotbar slot must be 1–9");
15545 }
15546 let ability_id = ability_id
15547 .map(str::trim)
15548 .filter(|id| !id.is_empty())
15549 .map(str::to_string);
15550 self.seq += 1;
15551 self.session
15552 .submit_intent(Intent::SetHotbarSlot {
15553 entity_id: self.state.entity_id,
15554 slot,
15555 ability_id: ability_id.clone(),
15556 seq: self.seq,
15557 })
15558 .await?;
15559 self.state.intents_sent += 1;
15560 let idx = (slot - 1) as usize;
15561 if self.state.hotbar.len() < 9 {
15562 self.state.hotbar.resize(9, None);
15563 }
15564 if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
15565 *slot_mut = ability_id.clone();
15566 }
15567 match ability_id {
15568 Some(id) => {
15569 let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
15570 format!("use {tid}")
15571 } else {
15572 id
15573 };
15574 self.state.push_log(format!("Hotbar {slot} → {label}"))
15575 }
15576 None => self.state.push_log(format!("Hotbar {slot} cleared")),
15577 }
15578 Ok(())
15579 }
15580
15581 pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
15582 self.state.npc_verb_options()
15583 }
15584
15585 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
15586 let Some(npc_id) = self.state.npc_verb_target.clone() else {
15587 return Ok(());
15588 };
15589 let options = self.npc_verb_options();
15590 let choice = options
15591 .get(self.state.npc_verb_index)
15592 .cloned()
15593 .unwrap_or_else(GameState::talk_choice);
15594 match choice.action {
15595 NpcVerbAction::QuestGive { quest_id } => {
15596 self.submit_npc_quest_turn_in(&npc_id, Some(&quest_id))
15597 .await?;
15598 self.state.show_npc_verb_menu = false;
15599 }
15600 NpcVerbAction::Talk => {
15601 self.open_npc_talk(&npc_id, None).await?;
15602 }
15603 NpcVerbAction::QuestTalk { quest_id } => {
15604 self.open_npc_talk(&npc_id, Some(&quest_id)).await?;
15605 }
15606 NpcVerbAction::Trade | NpcVerbAction::Bank | NpcVerbAction::Storage | NpcVerbAction::Market => {
15607 self.seq += 1;
15608 self.session
15609 .submit_intent(Intent::Interact {
15610 entity_id: self.state.entity_id,
15611 target_id: npc_id,
15612 seq: self.seq,
15613 })
15614 .await?;
15615 self.state.intents_sent += 1;
15616 }
15617 }
15618 Ok(())
15619 }
15620
15621 async fn open_npc_talk(&mut self, npc_id: &str, quest_id: Option<&str>) -> anyhow::Result<()> {
15622 self.seq += 1;
15623 self.session
15624 .submit_intent(Intent::NpcTalkOpen {
15625 entity_id: self.state.entity_id,
15626 npc_id: npc_id.to_string(),
15627 quest_id: quest_id.map(str::to_string),
15628 seq: self.seq,
15629 })
15630 .await?;
15631 self.state.intents_sent += 1;
15632 Ok(())
15633 }
15634
15635 async fn submit_npc_quest_turn_in(
15636 &mut self,
15637 npc_id: &str,
15638 quest_id: Option<&str>,
15639 ) -> anyhow::Result<()> {
15640 let catalog_ref = self.state.npc_quest_catalog_ref(npc_id);
15641 let pending: Vec<(String, u32, String)> = self
15642 .state
15643 .quest_log
15644 .iter()
15645 .filter(|q| {
15646 q.status == flatland_protocol::QuestStatusView::Active
15647 && quest_id.is_none_or(|id| q.quest_id == id)
15648 })
15649 .flat_map(|q| q.objectives.iter())
15650 .filter(|o| {
15651 !o.done
15652 && o.kind == "give_item"
15653 && o.npc_ref.as_deref() == Some(catalog_ref.as_str())
15654 })
15655 .filter_map(|o| {
15656 let template = o.item_template.clone()?;
15657 let remaining = o.required.saturating_sub(o.current);
15658 if remaining == 0 {
15659 return None;
15660 }
15661 Some((template, remaining, o.label.clone()))
15662 })
15663 .collect();
15664 if pending.is_empty() {
15665 self.state.push_log("Nothing to turn in here.");
15666 return Ok(());
15667 }
15668 let mut sent = 0u32;
15669 for (template, remaining, label) in pending {
15670 let held = self.state.count_inventory_template(&template);
15671 let qty = remaining.min(held);
15672 if qty == 0 {
15673 self.state.push_log(format!("Need {label}"));
15674 continue;
15675 }
15676 self.seq += 1;
15677 self.session
15678 .submit_intent(Intent::QuestGiveItem {
15679 entity_id: self.state.entity_id,
15680 npc_id: npc_id.to_string(),
15681 template_id: template,
15682 quantity: qty,
15683 seq: self.seq,
15684 })
15685 .await?;
15686 self.state.intents_sent += 1;
15687 sent += 1;
15688 }
15689 if sent > 0 {
15690 self.state.push_log("Turning in quest items.");
15691 }
15692 Ok(())
15693 }
15694
15695 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
15696 let Some(chat) = self.state.npc_chat.clone() else {
15697 return Ok(());
15698 };
15699 let message = chat.input.trim().to_string();
15700 if message.is_empty() || chat.pending {
15701 return Ok(());
15702 }
15703 if let Some(c) = self.state.npc_chat.as_mut() {
15704 c.lines.push(format!("You: {message}"));
15705 c.input.clear();
15706 c.pending = true;
15707 }
15708 self.seq += 1;
15709 self.session
15710 .submit_intent(Intent::NpcTalkSay {
15711 entity_id: self.state.entity_id,
15712 npc_id: chat.npc_id,
15713 message,
15714 seq: self.seq,
15715 })
15716 .await?;
15717 self.state.intents_sent += 1;
15718 Ok(())
15719 }
15720
15721 pub async fn npc_talk_topic(&mut self, index: usize) -> anyhow::Result<()> {
15722 let topic = self
15723 .state
15724 .npc_chat
15725 .as_ref()
15726 .and_then(|c| c.suggested_topics.get(index))
15727 .cloned();
15728 let Some(topic) = topic else {
15729 return Ok(());
15730 };
15731 if let Some(c) = self.state.npc_chat.as_mut() {
15732 if c.pending {
15733 return Ok(());
15734 }
15735 c.input = topic;
15736 }
15737 self.npc_talk_send().await
15738 }
15739
15740 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
15741 let return_to_verbs = self.state.npc_verb_target.is_some();
15742 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
15743 self.state.show_npc_chat = false;
15744 if return_to_verbs {
15745 self.state.show_npc_verb_menu = true;
15746 }
15747 return Ok(());
15748 };
15749 self.seq += 1;
15750 self.session
15751 .submit_intent(Intent::NpcTalkClose {
15752 entity_id: self.state.entity_id,
15753 npc_id,
15754 seq: self.seq,
15755 })
15756 .await?;
15757 self.state.intents_sent += 1;
15758 self.state.show_npc_chat = false;
15759 self.state.npc_chat = None;
15760 if return_to_verbs {
15761 self.state.show_npc_verb_menu = true;
15762 self.state.npc_verb_notice = None;
15763 }
15764 Ok(())
15765 }
15766
15767 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
15769 if self.state.show_quest_offer
15770 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
15771 {
15772 self.quest_offer_decline();
15773 return Ok(());
15774 }
15775 if self.state.show_npc_chat {
15776 return self.npc_talk_close().await;
15777 }
15778 if self.state.show_shop_menu {
15779 return self.back_from_shop_menu().await;
15780 }
15781 if self.state.bank_panel.is_some() {
15782 if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
15783 self.bank_transfer_back();
15784 return Ok(());
15785 }
15786 return self.close_bank_panel().await;
15787 }
15788 if self.state.storage_panel.is_some() {
15789 if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
15790 self.storage_ui_back();
15791 return Ok(());
15792 }
15793 return self.close_storage_panel().await;
15794 }
15795 if self.state.market_panel.is_some() {
15796 if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
15797 self.market_ui_back();
15798 return Ok(());
15799 }
15800 if self.state.market_buy_confirm.is_some() {
15801 self.state.market_buy_confirm = None;
15802 return Ok(());
15803 }
15804 return self.close_market_panel().await;
15805 }
15806 if self.state.show_npc_verb_menu {
15807 self.state.show_npc_verb_menu = false;
15808 self.state.npc_verb_target = None;
15809 }
15810 Ok(())
15811 }
15812
15813 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
15814 self.seq += 1;
15815 self.session
15816 .submit_intent(Intent::TestDamage {
15817 entity_id: self.state.entity_id,
15818 amount,
15819 seq: self.seq,
15820 })
15821 .await?;
15822 self.state.intents_sent += 1;
15823 Ok(())
15824 }
15825
15826 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
15827 self.cycle_combat_target_slot(1, reverse).await
15828 }
15829
15830 pub async fn cycle_combat_target_slot(
15831 &mut self,
15832 slot_index: u8,
15833 reverse: bool,
15834 ) -> anyhow::Result<()> {
15835 if !self.state.is_alive() {
15836 anyhow::bail!("you are dead");
15837 }
15838 let candidates = self.state.candidates_for_slot(slot_index);
15839 if candidates.is_empty() {
15840 anyhow::bail!("no targets nearby");
15841 }
15842 let current = self.state.target_for_slot(slot_index);
15843 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
15844 let next_idx = match idx {
15845 None => 0,
15846 Some(i) if reverse => {
15847 if i == 0 {
15848 candidates.len() - 1
15849 } else {
15850 i - 1
15851 }
15852 }
15853 Some(i) => (i + 1) % candidates.len(),
15854 };
15855 if idx == Some(next_idx) && candidates.len() == 1 {
15856 self.clear_combat_target_slot(slot_index).await?;
15857 return Ok(());
15858 }
15859 let (target_id, label) = candidates[next_idx].clone();
15860 self.set_combat_target_slot(slot_index, target_id, &label)
15861 .await
15862 }
15863
15864 pub async fn set_combat_target_slot(
15865 &mut self,
15866 slot_index: u8,
15867 target_id: EntityId,
15868 label: &str,
15869 ) -> anyhow::Result<()> {
15870 if !self.state.is_alive() {
15871 anyhow::bail!("you are dead");
15872 }
15873 self.seq += 1;
15874 self.session
15875 .submit_intent(Intent::SetTargetSlot {
15876 entity_id: self.state.entity_id,
15877 slot_index,
15878 target_id,
15879 seq: self.seq,
15880 })
15881 .await?;
15882 self.state.intents_sent += 1;
15883 if slot_index == 1 {
15884 self.state.combat_target = Some(target_id);
15885 self.state.combat_target_label = Some(label.to_string());
15886 }
15887 self.state
15888 .push_log(format!("Slot {slot_index} target: {label}"));
15889 Ok(())
15890 }
15891
15892 pub async fn set_combat_target(
15893 &mut self,
15894 target_id: EntityId,
15895 label: &str,
15896 ) -> anyhow::Result<()> {
15897 self.set_combat_target_slot(1, target_id, label).await
15898 }
15899
15900 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
15901 if slot_index == 1 && self.state.combat_target.is_none() {
15902 return Ok(());
15903 }
15904 self.seq += 1;
15905 self.session
15906 .submit_intent(Intent::ClearTargetSlot {
15907 entity_id: self.state.entity_id,
15908 slot_index,
15909 seq: self.seq,
15910 })
15911 .await?;
15912 if slot_index == 1 {
15913 self.state.combat_target = None;
15914 self.state.combat_target_label = None;
15915 }
15916 self.state.intents_sent += 1;
15917 self.state
15918 .push_log(format!("Slot {slot_index} target cleared"));
15919 Ok(())
15920 }
15921
15922 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
15923 self.clear_combat_target_slot(1).await
15924 }
15925
15926 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
15927 if !self.state.is_alive() {
15928 anyhow::bail!("you are dead");
15929 }
15930 self.seq += 1;
15931 self.session
15932 .submit_intent(Intent::AdvanceRotation {
15933 entity_id: self.state.entity_id,
15934 slot_index,
15935 seq: self.seq,
15936 })
15937 .await?;
15938 self.state.intents_sent += 1;
15939 Ok(())
15940 }
15941
15942 pub async fn assign_slot_preset(
15943 &mut self,
15944 slot_index: u8,
15945 preset_id: &str,
15946 ) -> anyhow::Result<()> {
15947 if !self.state.is_alive() {
15948 anyhow::bail!("you are dead");
15949 }
15950 self.seq += 1;
15951 self.session
15952 .submit_intent(Intent::AssignSlotPreset {
15953 entity_id: self.state.entity_id,
15954 slot_index,
15955 preset_id: preset_id.to_string(),
15956 seq: self.seq,
15957 })
15958 .await?;
15959 self.state.intents_sent += 1;
15960 if let Some(slot) = self
15961 .state
15962 .combat_slots
15963 .iter_mut()
15964 .find(|s| s.slot_index == slot_index)
15965 {
15966 slot.preset_id = Some(preset_id.to_string());
15967 if let Some(preset) = self
15968 .state
15969 .rotation_presets
15970 .iter()
15971 .find(|p| p.id == preset_id)
15972 {
15973 slot.preset_label = Some(preset.label.clone());
15974 slot.rotation = preset.abilities.clone();
15975 slot.rotation_index = 0;
15976 }
15977 }
15978 self.state
15979 .push_log(format!("T{slot_index} loadout → {preset_id}"));
15980 Ok(())
15981 }
15982
15983 pub async fn cast_ability(
15984 &mut self,
15985 ability_id: &str,
15986 target_id: Option<EntityId>,
15987 ) -> anyhow::Result<()> {
15988 if !self.state.is_alive() {
15989 anyhow::bail!("you are dead");
15990 }
15991 let allows_ground = self.state.ability_allows_ground(ability_id);
15992 let requires_ground = self.state.ability_requires_ground(ability_id);
15993 if requires_ground && self.state.ground_target.is_none() {
15994 anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
15995 }
15996 let (resolved_target_id, target_point) = if allows_ground {
15997 if let Some((x, y, z)) = self.state.ground_target {
15998 (
15999 target_id.unwrap_or(self.state.entity_id),
16000 Some(flatland_protocol::AimPoint { x, y, z }),
16001 )
16002 } else {
16003 (
16004 target_id
16005 .or_else(|| self.state.target_for_slot(2))
16006 .or_else(|| self.state.target_for_slot(1))
16007 .unwrap_or(self.state.entity_id),
16008 None,
16009 )
16010 }
16011 } else {
16012 (
16013 target_id
16014 .or_else(|| self.state.target_for_slot(2))
16015 .or_else(|| self.state.target_for_slot(1))
16016 .unwrap_or(self.state.entity_id),
16017 None,
16018 )
16019 };
16020 self.seq += 1;
16021 self.session
16022 .submit_intent(Intent::Cast {
16023 entity_id: self.state.entity_id,
16024 ability_id: ability_id.to_string(),
16025 target_id: resolved_target_id,
16026 target_point,
16027 seq: self.seq,
16028 })
16029 .await?;
16030 self.state.intents_sent += 1;
16031 match target_point {
16032 Some(point) => self.state.push_log(format!(
16033 "Cast {ability_id} → ({:.1}, {:.1})",
16034 point.x, point.y
16035 )),
16036 None => self
16037 .state
16038 .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
16039 }
16040 Ok(())
16041 }
16042
16043 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
16044 self.seq += 1;
16045 self.session
16046 .submit_intent(Intent::UpsertRotationPreset {
16047 entity_id: self.state.entity_id,
16048 preset: preset.clone(),
16049 seq: self.seq,
16050 })
16051 .await?;
16052 self.state.intents_sent += 1;
16053 if let Some(existing) = self
16054 .state
16055 .rotation_presets
16056 .iter_mut()
16057 .find(|p| p.id == preset.id)
16058 {
16059 *existing = preset.clone();
16060 } else {
16061 self.state.rotation_presets.push(preset.clone());
16062 }
16063 for slot in &mut self.state.combat_slots {
16064 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
16065 slot.preset_label = Some(preset.label.clone());
16066 slot.rotation = preset.abilities.clone();
16067 }
16068 }
16069 self.state
16070 .push_log(format!("Saved rotation: {}", preset.label));
16071 Ok(())
16072 }
16073
16074 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
16075 self.seq += 1;
16076 self.session
16077 .submit_intent(Intent::DeleteRotationPreset {
16078 entity_id: self.state.entity_id,
16079 preset_id: preset_id.to_string(),
16080 seq: self.seq,
16081 })
16082 .await?;
16083 self.state.intents_sent += 1;
16084 self.state.rotation_presets.retain(|p| p.id != preset_id);
16085 for slot in &mut self.state.combat_slots {
16086 if slot.preset_id.as_deref() == Some(preset_id) {
16087 slot.preset_id = None;
16088 slot.preset_label = None;
16089 slot.rotation.clear();
16090 slot.rotation_index = 0;
16091 }
16092 }
16093 self.state
16094 .push_log(format!("Deleted rotation: {preset_id}"));
16095 Ok(())
16096 }
16097
16098 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
16099 if !self.state.is_alive() {
16100 anyhow::bail!("you are dead");
16101 }
16102 let enabled = !self
16103 .state
16104 .combat_slots
16105 .iter()
16106 .find(|s| s.slot_index == slot_index)
16107 .map(|s| s.auto_enabled)
16108 .unwrap_or(false);
16109 self.seq += 1;
16110 self.session
16111 .submit_intent(Intent::SetAutoAttack {
16112 entity_id: self.state.entity_id,
16113 slot_index,
16114 enabled,
16115 seq: self.seq,
16116 })
16117 .await?;
16118 if slot_index == 1 {
16119 self.state.auto_attack = enabled;
16120 }
16121 self.state.intents_sent += 1;
16122 self.state.push_log(format!(
16123 "T{slot_index} auto {}",
16124 if enabled { "ON" } else { "OFF" }
16125 ));
16126 Ok(())
16127 }
16128
16129 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
16130 if !self.state.connected {
16131 anyhow::bail!("not connected");
16132 }
16133 if !self.state.is_alive() {
16134 anyhow::bail!("you are dead");
16135 }
16136 let (px, py) = self.state.player_position();
16137 if self
16138 .state
16139 .ground_drops
16140 .iter()
16141 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
16142 {
16143 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
16144 }
16145 self.seq += 1;
16146 self.session
16147 .submit_intent(Intent::Pickup {
16148 entity_id: self.state.entity_id,
16149 drop_id: None,
16150 seq: self.seq,
16151 })
16152 .await?;
16153 self.state.intents_sent += 1;
16154 self.state.push_audio(crate::social::AudioCue::LootPickup);
16155 Ok(())
16156 }
16157
16158 pub async fn dodge(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16159 if !self.state.is_alive() {
16160 anyhow::bail!("you are dead");
16161 }
16162 self.seq += 1;
16164 self.session
16165 .submit_intent(Intent::Dodge {
16166 entity_id: self.state.entity_id,
16167 forward,
16168 strafe,
16169 seq: self.seq,
16170 })
16171 .await?;
16172 self.state.intents_sent += 1;
16173 self.state.push_log("Dodge!");
16174 self.state.push_audio(crate::social::AudioCue::CombatDodge);
16175 Ok(())
16176 }
16177
16178 pub async fn lunge(&mut self) -> anyhow::Result<()> {
16179 if !self.state.is_alive() {
16180 anyhow::bail!("you are dead");
16181 }
16182 let (forward, strafe) = self.last_move_axes();
16183 self.seq += 1;
16184 self.session
16185 .submit_intent(Intent::Lunge {
16186 entity_id: self.state.entity_id,
16187 forward,
16188 strafe,
16189 seq: self.seq,
16190 })
16191 .await?;
16192 self.state.intents_sent += 1;
16193 self.state.push_log("Lunge!");
16194 Ok(())
16195 }
16196
16197 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16198 if !self.state.is_alive() {
16199 anyhow::bail!("you are dead");
16200 }
16201 self.seq += 1;
16202 self.session
16203 .submit_intent(Intent::DirectionalJump {
16204 entity_id: self.state.entity_id,
16205 forward,
16206 strafe,
16207 seq: self.seq,
16208 })
16209 .await?;
16210 self.state.intents_sent += 1;
16211 self.state.push_log("Jump!");
16212 Ok(())
16213 }
16214
16215 pub fn last_move_axes(&self) -> (f32, f32) {
16217 (self.last_move_forward, self.last_move_strafe)
16218 }
16219
16220 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
16221 if !self.state.is_alive() {
16222 anyhow::bail!("you are dead");
16223 }
16224 self.seq += 1;
16225 self.session
16226 .submit_intent(Intent::Block {
16227 entity_id: self.state.entity_id,
16228 enabled,
16229 seq: self.seq,
16230 })
16231 .await?;
16232 self.state.intents_sent += 1;
16233 if enabled {
16234 self.state.push_log("Blocking");
16235 self.state.push_audio(crate::social::AudioCue::CombatBlock);
16236 }
16237 Ok(())
16238 }
16239
16240 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
16241 if !self.state.is_alive() {
16242 anyhow::bail!("you are dead");
16243 }
16244 self.seq += 1;
16245 self.session
16246 .submit_intent(Intent::EquipMainhand {
16247 entity_id: self.state.entity_id,
16248 template_id,
16249 instance_id: None,
16250 seq: self.seq,
16251 })
16252 .await?;
16253 self.state.intents_sent += 1;
16254 Ok(())
16255 }
16256
16257 pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
16259 let idx = self.state.equip_menu_index;
16260 let slots = equip_paperdoll_rows(&self.state);
16261 let Some(row) = slots.get(idx) else {
16262 return Ok(());
16263 };
16264 match row {
16265 EquipPaperdollRow::Body { slot, filled } => {
16266 if *filled {
16267 self.equip_worn(*slot, None).await
16268 } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
16269 self.equip_worn(*slot, Some(inst)).await
16270 } else {
16271 self.state
16272 .push_log(format!("No item for {}", body_slot_label(*slot)));
16273 Ok(())
16274 }
16275 }
16276 EquipPaperdollRow::Mainhand { filled } => {
16277 if *filled {
16278 self.unequip_mainhand().await
16279 } else if let Some(tid) = first_inventory_weapon(&self.state) {
16280 self.equip_mainhand(Some(tid)).await
16281 } else {
16282 self.state.push_log("No weapon in inventory".to_string());
16283 Ok(())
16284 }
16285 }
16286 EquipPaperdollRow::Offhand { filled, locked } => {
16287 if *locked {
16288 self.state
16289 .push_log("Offhand locked — two-handed weapon equipped".to_string());
16290 Ok(())
16291 } else if *filled {
16292 self.unequip_offhand().await
16293 } else if let Some(tid) = first_inventory_offhand(&self.state) {
16294 self.equip_offhand(Some(tid)).await
16295 } else {
16296 self.state
16297 .push_log("No offhand item in inventory".to_string());
16298 Ok(())
16299 }
16300 }
16301 }
16302 }
16303
16304 pub async fn say(
16305 &mut self,
16306 channel: flatland_protocol::ChatChannel,
16307 text: &str,
16308 ) -> anyhow::Result<()> {
16309 self.say_to(channel, text, None).await
16310 }
16311
16312 pub async fn say_to(
16313 &mut self,
16314 channel: flatland_protocol::ChatChannel,
16315 text: &str,
16316 to_entity: Option<EntityId>,
16317 ) -> anyhow::Result<()> {
16318 self.seq += 1;
16319 self.session
16320 .submit_intent(Intent::Say {
16321 entity_id: self.state.entity_id,
16322 channel,
16323 text: text.to_string(),
16324 to_entity,
16325 seq: self.seq,
16326 })
16327 .await?;
16328 self.state.intents_sent += 1;
16329 Ok(())
16330 }
16331
16332 pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
16333 let Some(peer) = self.state.player_verbs.target_entity else {
16334 return Ok(());
16335 };
16336 let label = self.state.player_verbs.target_label.clone();
16337 let choice = crate::social::PlayerVerbState::options()
16338 .get(self.state.player_verbs.index)
16339 .copied()
16340 .unwrap_or("Whisper");
16341 self.state.player_verbs.close();
16342 match choice {
16343 "Trade" => {
16344 self.seq += 1;
16347 self.session
16348 .submit_intent(Intent::TradeRequest {
16349 entity_id: self.state.entity_id,
16350 peer_entity_id: peer,
16351 seq: self.seq,
16352 })
16353 .await?;
16354 self.state.intents_sent += 1;
16355 self.state.social_chat.push_system(format!(
16356 "Trade request sent to {label} — waiting for accept"
16357 ));
16358 }
16359 "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
16360 _ => self.state.social_chat.focus_nearby(),
16361 }
16362 Ok(())
16363 }
16364
16365 pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
16366 let Some(pending) = self.state.social_chat.pending_trade.take() else {
16367 return Ok(());
16368 };
16369 self.seq += 1;
16370 self.session
16371 .submit_intent(Intent::TradeRespond {
16372 entity_id: self.state.entity_id,
16373 peer_entity_id: pending.from_entity,
16374 accept,
16375 seq: self.seq,
16376 })
16377 .await?;
16378 self.state.intents_sent += 1;
16379 if accept {
16380 self.state
16381 .social_chat
16382 .push_system(format!("Accepted trade with {}", pending.from_name));
16383 } else {
16384 self.state
16385 .social_chat
16386 .push_system(format!("Declined trade with {}", pending.from_name));
16387 }
16388 Ok(())
16389 }
16390
16391 pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
16392 let text = self.state.social_chat.buffer.trim().to_string();
16393 if text.is_empty() {
16394 return Ok(());
16395 }
16396 self.state.social_chat.buffer.clear();
16397 if crate::social::is_chat_slash_line(&text) {
16398 match crate::social::parse_chat_slash(&text) {
16399 Some(cmd) => return self.apply_chat_slash(cmd).await,
16400 None => {
16401 self.state.social_chat.push_system(format!(
16402 "Unknown command — {}",
16403 crate::social::chat_slash_help_text()
16404 ));
16405 return Ok(());
16406 }
16407 }
16408 }
16409 let thread = self.state.social_chat.thread;
16410 let channel = thread.channel();
16411 let to = thread.to_entity();
16412 if let Some(peer) = to {
16413 let label = self.state.social_chat.peer_label.clone();
16414 self.state
16415 .social_chat
16416 .remember_whisper_peer(peer, &label, channel);
16417 }
16418 self.say_to(channel, &text, to).await
16419 }
16420
16421 async fn apply_chat_slash(
16422 &mut self,
16423 cmd: crate::social::ChatSlashCommand,
16424 ) -> anyhow::Result<()> {
16425 use crate::social::{chat_slash_help_text, ChatSlashCommand};
16426 match cmd {
16427 ChatSlashCommand::Help => {
16428 self.state
16429 .social_chat
16430 .push_system(chat_slash_help_text().to_string());
16431 Ok(())
16432 }
16433 ChatSlashCommand::Nearby { message } => {
16434 self.state.social_chat.focus_nearby();
16435 self.state
16436 .social_chat
16437 .push_system("Nearby speech — everyone close can hear");
16438 if let Some(msg) = message {
16439 self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
16440 .await
16441 } else {
16442 Ok(())
16443 }
16444 }
16445 ChatSlashCommand::Reply { message } => {
16446 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16447 self.state
16448 .social_chat
16449 .push_system("No one to reply to — wait for a whisper, or /whisper Name");
16450 return Ok(());
16451 };
16452 let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
16453 self.state
16454 .social_chat
16455 .set_whisper_thread(peer.entity_id, &peer.label, stone);
16456 self.state.social_chat.push_system(format!(
16457 "Replying to {} — type and Enter · /nearby",
16458 peer.label
16459 ));
16460 if let Some(msg) = message {
16461 self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
16462 } else {
16463 Ok(())
16464 }
16465 }
16466 ChatSlashCommand::Whisper { name, message } => {
16467 let (peer_id, label, stone) = if let Some(name) = name {
16468 match self.resolve_whisper_target(&name) {
16469 Ok(t) => t,
16470 Err(err) => {
16471 self.state.social_chat.push_system(err);
16472 return Ok(());
16473 }
16474 }
16475 } else {
16476 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16477 self.state.social_chat.push_system(
16478 "Usage: /whisper Name [message] · or /reply after someone whispers you",
16479 );
16480 return Ok(());
16481 };
16482 (
16483 peer.entity_id,
16484 peer.label,
16485 peer.channel == flatland_protocol::ChatChannel::WhisperStone,
16486 )
16487 };
16488 self.state
16489 .social_chat
16490 .set_whisper_thread(peer_id, &label, stone);
16491 let channel = if stone {
16492 flatland_protocol::ChatChannel::WhisperStone
16493 } else {
16494 flatland_protocol::ChatChannel::Whisper
16495 };
16496 if let Some(msg) = message {
16497 self.state
16498 .social_chat
16499 .push_system(format!("Whisper → {label}"));
16500 self.say_to(channel, &msg, Some(peer_id)).await
16501 } else {
16502 self.state.social_chat.push_system(format!(
16503 "Whispering {label} — type and Enter · Esc / /nearby cancels"
16504 ));
16505 Ok(())
16506 }
16507 }
16508 }
16509 }
16510
16511 fn resolve_whisper_target(&self, name: &str) -> Result<(EntityId, String, bool), String> {
16513 let needle = name.trim().to_ascii_lowercase();
16514 if needle.is_empty() {
16515 return Err("Usage: /whisper Name [message]".into());
16516 }
16517 let mut candidates: Vec<(EntityId, String)> = self
16518 .state
16519 .entities
16520 .iter()
16521 .filter(|e| e.id != self.state.entity_id)
16522 .filter(|e| !e.label.trim().is_empty())
16523 .filter(|e| e.vitals.is_some())
16524 .filter(|e| !self.state.npcs.iter().any(|n| n.id == e.id.to_string()))
16525 .filter(|e| !self.state.hired_workers.iter().any(|w| w.entity_id == e.id))
16526 .map(|e| (e.id, e.label.clone()))
16527 .collect();
16528
16529 if let Some(last) = &self.state.social_chat.last_whisper_peer {
16531 if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
16532 candidates.push((last.entity_id, last.label.clone()));
16533 }
16534 }
16535
16536 let exact: Vec<_> = candidates
16537 .iter()
16538 .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
16539 .cloned()
16540 .collect();
16541 let pool = if exact.len() == 1 {
16542 exact
16543 } else if exact.len() > 1 {
16544 return Err(format!(
16545 "Several players named '{name}' nearby — move closer and try again"
16546 ));
16547 } else {
16548 let starts: Vec<_> = candidates
16549 .iter()
16550 .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
16551 .cloned()
16552 .collect();
16553 if starts.len() == 1 {
16554 starts
16555 } else if starts.len() > 1 {
16556 let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
16557 return Err(format!(
16558 "Ambiguous name '{name}' — matches: {}",
16559 names.join(", ")
16560 ));
16561 } else {
16562 let contains: Vec<_> = candidates
16563 .iter()
16564 .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
16565 .cloned()
16566 .collect();
16567 if contains.len() == 1 {
16568 contains
16569 } else if contains.is_empty() {
16570 return Err(format!(
16571 "No player matching '{name}' in range — get closer or check the spelling"
16572 ));
16573 } else {
16574 let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
16575 return Err(format!(
16576 "Ambiguous name '{name}' — matches: {}",
16577 names.join(", ")
16578 ));
16579 }
16580 }
16581 };
16582
16583 let (id, label) = pool.into_iter().next().unwrap();
16584 let stone = self
16585 .state
16586 .social_chat
16587 .last_whisper_peer
16588 .as_ref()
16589 .is_some_and(|p| {
16590 p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone
16591 });
16592 Ok((id, label, stone))
16593 }
16594
16595 pub async fn trade_present_selected(
16596 &mut self,
16597 item_instance_id: uuid::Uuid,
16598 ) -> anyhow::Result<()> {
16599 self.trade_present_quantity(item_instance_id, None).await
16600 }
16601
16602 pub async fn trade_present_quantity(
16603 &mut self,
16604 item_instance_id: uuid::Uuid,
16605 quantity: Option<u32>,
16606 ) -> anyhow::Result<()> {
16607 self.seq += 1;
16608 self.session
16609 .submit_intent(Intent::TradePresent {
16610 entity_id: self.state.entity_id,
16611 item_instance_id,
16612 quantity,
16613 seq: self.seq,
16614 })
16615 .await?;
16616 self.state.intents_sent += 1;
16617 self.state.trade_ui.qty_entry = None;
16618 self.state.trade_ui.picking_inventory = false;
16619 Ok(())
16620 }
16621
16622 pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
16624 if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
16625 let qty = self.state.trade_ui.present_quantity();
16626 return self
16627 .trade_present_quantity(entry.item_instance_id, qty)
16628 .await;
16629 }
16630 if !self.state.trade_ui.picking_inventory {
16631 return Ok(());
16632 }
16633 let stacks = self.state.trade_presentable_stacks();
16634 let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
16635 return Ok(());
16636 };
16637 let Some(id) = stack.item_instance_id else {
16638 return Ok(());
16639 };
16640 let label = stack
16641 .display_name
16642 .clone()
16643 .unwrap_or_else(|| stack.template_id.clone());
16644 if stack.quantity <= 1 {
16645 self.trade_present_quantity(id, Some(1)).await
16646 } else {
16647 self.state
16648 .trade_ui
16649 .begin_qty_entry(id, label, stack.quantity);
16650 Ok(())
16651 }
16652 }
16653
16654 pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
16655 self.seq += 1;
16656 self.session
16657 .submit_intent(Intent::TradeSetReady {
16658 entity_id: self.state.entity_id,
16659 ready,
16660 seq: self.seq,
16661 })
16662 .await?;
16663 self.state.intents_sent += 1;
16664 Ok(())
16665 }
16666
16667 pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
16668 self.seq += 1;
16669 self.session
16670 .submit_intent(Intent::TradeCancel {
16671 entity_id: self.state.entity_id,
16672 seq: self.seq,
16673 })
16674 .await?;
16675 self.state.intents_sent += 1;
16676 self.state.trade_ui.close();
16677 Ok(())
16678 }
16679
16680 pub async fn destroy_whisper_stone(
16681 &mut self,
16682 item_instance_id: uuid::Uuid,
16683 ) -> anyhow::Result<()> {
16684 self.seq += 1;
16685 self.session
16686 .submit_intent(Intent::DestroyWhisperStone {
16687 entity_id: self.state.entity_id,
16688 item_instance_id,
16689 seq: self.seq,
16690 })
16691 .await?;
16692 self.state.intents_sent += 1;
16693 Ok(())
16694 }
16695
16696 pub async fn stop(&mut self) -> anyhow::Result<()> {
16697 self.seq += 1;
16698 self.session
16699 .submit_intent(Intent::Stop {
16700 entity_id: self.state.entity_id,
16701 seq: self.seq,
16702 })
16703 .await?;
16704 self.state.intents_sent += 1;
16705 Ok(())
16706 }
16707
16708 pub fn disconnect(&self) {
16709 self.session.disconnect();
16710 }
16711}
16712
16713fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
16714 let dx = ax - bx;
16715 let dy = ay - by;
16716 (dx * dx + dy * dy).sqrt()
16717}
16718
16719#[cfg(test)]
16720mod tests {
16721 use std::collections::BTreeMap;
16722
16723 use super::*;
16724 use flatland_protocol::{
16725 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
16726 };
16727
16728 fn sample_state() -> GameState {
16729 let mut state = GameState {
16730 session_id: 1,
16731 entity_id: 1,
16732 character_id: None,
16733 tick: 0,
16734 chunk_rev: 0,
16735 content_rev: 0,
16736 publish_rev: 0,
16737 entities: vec![EntityState {
16738 id: 1,
16739 label: "You".into(),
16740 transform: Transform {
16741 position: WorldCoord::surface(128.0, 128.0),
16742 yaw: 0.0,
16743 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
16744 },
16745 vitals: None,
16746 attributes: None,
16747 skills: None,
16748 inside_building: None,
16749 tile_id: None,
16750 paperdoll_ref: None,
16751 draw_scale: 1.0,
16752 presentation_state: None,
16753 sprite_mode: None,
16754 progression_xp: None,
16755 combat_cues: vec![],
16756 statuses: vec![],
16757 }],
16758 player: None,
16759 resource_nodes: vec![ResourceNodeView {
16760 id: "oak-1".into(),
16761 label: "Oak".into(),
16762 x: 130.0,
16763 y: 128.0,
16764 z: 0.0,
16765 item_template: "oak_log".into(),
16766 state: ResourceNodeState::Available,
16767 blocking: true,
16768 blocking_radius_m: 0.8,
16769 harvest_off: false,
16770 tile_id: None,
16771 yaw: 0.0,
16772 pitch: 0.0,
16773 roll: 0.0,
16774 draw_scale: 1.0,
16775 sprite_mode: None,
16776 growth_progress: None,
16777 presentation_state: None,
16778 channel_start_tick: None,
16779 channel_end_tick: None,
16780 harvest_drop_templates: vec![],
16781 }],
16782 ground_drops: vec![],
16783 placed_containers: vec![],
16784 buildings: vec![BuildingView {
16785 id: "broker-hut".into(),
16786 label: "Broker".into(),
16787 x: 148.0,
16788 y: 118.0,
16789 width_m: 8.0,
16790 depth_m: 6.0,
16791 interior_blueprint: Some("broker_hut".into()),
16792 tags: vec![],
16793 market_boundary_zone_ids: vec![],
16794 market_max_volume: None,
16795 wall_set: None,
16796 roof_set: None,
16797 }],
16798 doors: vec![flatland_protocol::DoorView {
16799 id: "door-1".into(),
16800 building_id: "broker-hut".into(),
16801 x: 148.0,
16802 y: 118.0,
16803 open: false,
16804 portal: Some("front".into()),
16805 locked: false,
16806 accessible: true,
16807 lock_id: None,
16808 }],
16809 interior_map: None,
16810 npcs: vec![],
16811 blueprints: vec![],
16812 building_materials: vec![],
16813 world_x0: 0.0,
16814 world_y0: 0.0,
16815 world_width_m: 256.0,
16816 world_height_m: 256.0,
16817 terrain_zones: Vec::new(),
16818 z_platforms: Vec::new(),
16819 z_transitions: Vec::new(),
16820 z_bands_outdoor_backup: None,
16821 world_clock: flatland_protocol::WorldClock::default(),
16822 inventory: std::collections::HashMap::new(),
16823 inventory_hints: std::collections::HashMap::new(),
16824 item_catalog: std::collections::HashMap::new(),
16825 logs: VecDeque::new(),
16826 intents_sent: 0,
16827 ticks_received: 0,
16828 connected: true,
16829 disconnect_reason: None,
16830 show_stats: false,
16831 hud_log_hidden: false,
16832 show_equip_menu: false,
16833 equip_menu_index: 0,
16834 show_craft_menu: false,
16835 show_plot_build_menu: false,
16836 plot_build_focus_wall: true,
16837 plot_build_wall_index: 0,
16838 plot_build_roof_index: 0,
16839 craft_menu_index: 0,
16840 craft_batch_quantity: 1,
16841 craft_tab: CraftTab::Ready,
16842 craft_filter: String::new(),
16843 craft_filter_focused: false,
16844 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
16845 show_shop_menu: false,
16846 shop_catalog: None,
16847 bank_panel: None,
16848 bank_menu_index: 0,
16849 bank_ui_mode: BankUiMode::Menu,
16850 storage_panel: None,
16851 market_panel: None,
16852 market_menu_index: 0,
16853 market_filter: String::new(),
16854 market_filter_focused: false,
16855 market_category_filter: None,
16856 market_buy_confirm: None,
16857 market_ui_mode: MarketUiMode::Browse,
16858 storage_menu_index: 0,
16859 storage_ui_mode: StorageUiMode::Menu,
16860 shop_tab: ShopTab::default(),
16861 shop_menu_index: 0,
16862 shop_quantity: 1,
16863 shop_trade_log: VecDeque::new(),
16864 show_npc_verb_menu: false,
16865 npc_verb_target: None,
16866 npc_verb_index: 0,
16867 npc_verb_notice: None,
16868 player_verbs: crate::social::PlayerVerbState::default(),
16869 social_chat: crate::social::SocialChatState::default(),
16870 trade_ui: crate::social::TradeUiState::default(),
16871 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
16872 show_npc_chat: false,
16873 npc_chat: None,
16874 show_inventory_menu: false,
16875 inventory_menu_index: 0,
16876 inventory_tab: InventoryTab::OnPerson,
16877 inventory_filter: String::new(),
16878 inventory_filter_focused: false,
16879 show_move_picker: false,
16880 show_rename_prompt: false,
16881 rename_plot_id: None,
16882 highlighted_plot_id: None,
16883 show_worker_rename: false,
16884 rename_buffer: String::new(),
16885 move_picker_index: 0,
16886 move_picker: None,
16887 show_grant_picker: false,
16888 grant_picker_index: 0,
16889 grant_picker: None,
16890 show_destroy_picker: false,
16891 destroy_confirm_pending: false,
16892 destroy_picker: None,
16893 combat_target: None,
16894 combat_target_label: None,
16895 ground_target: None,
16896 combat_fx: Vec::new(),
16897 ground_hazards: Vec::new(),
16898 property_zones: Vec::new(),
16899 tax_zones: Vec::new(),
16900 growth_zones: Vec::new(),
16901 biome_zones: Vec::new(),
16902 terrain_kind_nav: Vec::new(),
16903 property_plots: Vec::new(),
16904 property_plot_settings: None,
16905 claim_mode: None,
16906 relocate_mode: None,
16907 sell_plot_confirm: None,
16908 sell_plot_armed_at: None,
16909 show_plant_menu: false,
16910 plant_menu_index: 0,
16911 show_farm_access: false,
16912 farm_access_name_draft: String::new(),
16913 farm_access_discount_bps: 0,
16914 farm_access_index: 0,
16915 plant_quantity: 1,
16916 in_combat: false,
16917 auto_attack: true,
16918 combat_has_los: false,
16919 attack_cd_ticks: 0,
16920 gcd_ticks: 0,
16921 weapon_ability_id: "unarmed".into(),
16922 mainhand_template_id: None,
16923 mainhand_label: None,
16924 mainhand_instance_id: None,
16925 offhand_template_id: None,
16926 offhand_label: None,
16927 offhand_instance_id: None,
16928 mainhand_hand_slots: 1,
16929 defense: None,
16930 worn: BTreeMap::new(),
16931 carry_mass: 0.0,
16932 carry_mass_max: 0.0,
16933 encumbrance: flatland_protocol::EncumbranceState::Light,
16934 move_speed_mps: 0.0,
16935 move_speed_mult: 0.0,
16936 inventory_stacks: Vec::new(),
16937 keychain_stacks: Vec::new(),
16938 whisper_pouch_stacks: Vec::new(),
16939 combat_target_detail: None,
16940 statuses: Vec::new(),
16941 cast_progress: None,
16942 timed_channel: None,
16943 plot_build_offer: None,
16944 ability_cooldowns: Vec::new(),
16945 blocking_active: false,
16946 max_target_slots: 1,
16947 combat_slots: Vec::new(),
16948 rotation_presets: Vec::new(),
16949 known_abilities: Vec::new(),
16950 ability_meta: std::collections::HashMap::new(),
16951 ability_mastery: std::collections::HashMap::new(),
16952 hotbar: vec![None; 9],
16953 max_abilities_per_rotation: 0,
16954 show_loadout_menu: false,
16955 show_keychain_menu: false,
16956 keychain_menu_index: 0,
16957 show_rotation_editor: false,
16958 loadout_menu_index: 0,
16959 loadout_hotbar_slot: 1,
16960 loadout_ability_index: 0,
16961 loadout_focus_presets: false,
16962 rotation_editor: RotationEditorState::default(),
16963 harvest_in_progress: false,
16964 harvest_started_at: None,
16965 pending_craft_ack: None,
16966 craft_channel_blueprint_id: None,
16967 pending_worker_job_ack: None,
16968 attending_worker_instance_id: None,
16969 quest_log: Vec::new(),
16970 interactables: Vec::new(),
16971 ledger: None,
16972 career: None,
16973 character_sheet_tab: CharacterSheetTab::Character,
16974 ledger_period: LedgerPeriod::Day,
16975 show_quest_offer: false,
16976 pending_quest_offers: Vec::new(),
16977 quest_offer_index: 0,
16978 show_quest_menu: false,
16979 quest_menu_index: 0,
16980 quest_withdraw_confirm: false,
16981 hired_workers: Vec::new(),
16982 show_workers_menu: false,
16983 workers_menu_index: 0,
16984 worker_dismiss_confirmation: None,
16985 workers_menu_compact: false,
16986 worker_step_display: BTreeMap::new(),
16987 worker_error_display: BTreeMap::new(),
16988 worker_health_ring_until: BTreeMap::new(),
16989 pending_worker_hire_since: None,
16990 show_worker_give_picker: false,
16991 worker_give_picker_index: 0,
16992 worker_give_picker: None,
16993 show_worker_give_target_picker: false,
16994 worker_give_target_picker_index: 0,
16995 worker_give_target_picker: None,
16996 show_worker_take_picker: false,
16997 worker_take_picker_index: 0,
16998 worker_take_picker: None,
16999 show_worker_teach_picker: false,
17000 worker_teach_picker_index: 0,
17001 worker_teach_picker: None,
17002 worker_route_editor: None,
17003 progression_curve: None,
17004 };
17005 state.player = state.entities.first().cloned();
17006 state
17007 }
17008
17009 #[test]
17010 fn template_display_name_uses_item_catalog_for_uuid_ids() {
17011 let mut state = sample_state();
17012 let id = "7ac81ed5-e3d3-4b7f-a7fb-11d6c34a0995";
17013 assert_eq!(state.template_display_name(id), "Unknown item");
17014 state.item_catalog.insert(
17015 id.into(),
17016 ItemCatalogEntryView {
17017 template_id: id.into(),
17018 display_name: "Emerald".into(),
17019 category: "resource".into(),
17020 seed_for: None,
17021 },
17022 );
17023 assert_eq!(state.template_display_name(id), "Emerald");
17024 }
17025
17026 #[test]
17027 fn whisper_cancels_when_peer_walks_out_of_range() {
17028 let mut state = sample_state();
17029 state.player = state.entities.first().cloned();
17030 let mut peer = state.entities[0].clone();
17031 peer.id = 2;
17032 peer.label = "Ada".into();
17033 peer.transform.position = WorldCoord::surface(129.0, 128.0); state.entities.push(peer.clone());
17035 state.social_chat.focus_whisper(2, "Ada");
17036 state.refresh_whisper_range();
17037 assert!(matches!(
17038 state.social_chat.thread,
17039 crate::social::ChatThreadKind::Whisper { peer: 2 }
17040 ));
17041
17042 peer.transform.position = WorldCoord::surface(132.0, 128.0); state.entities[1] = peer;
17044 state.refresh_whisper_range();
17045 assert_eq!(
17046 state.social_chat.thread,
17047 crate::social::ChatThreadKind::Nearby
17048 );
17049 assert!(!state.social_chat.input_focused);
17050 }
17051
17052 #[test]
17053 fn probe_use_world_hired_worker_manage() {
17054 let mut state = sample_state();
17055 state
17056 .hired_workers
17057 .push(flatland_protocol::HiredWorkerView {
17058 instance_id: "worker-1".into(),
17059 entity_id: 42,
17060 def_id: "worker_laborer".into(),
17061 label: "Sam".into(),
17062 x: 129.0,
17063 y: 128.0,
17064 z: 0.0,
17065 mode: flatland_protocol::WorkerModeView::JobLoop,
17066 state: flatland_protocol::WorkerStateView::Working,
17067 step_label: "cultivate".into(),
17068 vitals: flatland_protocol::WorkerVitalsSummary {
17069 health_pct: 100.0,
17070 stamina_pct: 100.0,
17071 mana_pct: 100.0,
17072 hunger_pct: 100.0,
17073 thirst_pct: 100.0,
17074 },
17075 carry_pct: 0.0,
17076 last_error: None,
17077 wage_copper_per_interval: 1,
17078 effective_wage_copper: 1,
17079 wage_meters_walked: 0.0,
17080 lodging_container_id: None,
17081 route: None,
17082 route_stop_index: None,
17083 known_blueprint_ids: Vec::new(),
17084 level: 1,
17085 worker_xp: 0.0,
17086 inventory: Vec::new(),
17087 equipment: flatland_protocol::WorkerEquipmentView::default(),
17088 issue_hint: None,
17089 });
17090 let probe = state.probe_use_world();
17091 let primary = probe.primary.expect("primary");
17092 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17093 assert_eq!(primary.id, "worker-1");
17094 assert!(primary.hint_line().contains("Manage"));
17095 assert!(primary.hint_line().contains("Sam"));
17096 assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17097 }
17098
17099 fn sample_hired_worker(x: f32, y: f32) -> flatland_protocol::HiredWorkerView {
17100 flatland_protocol::HiredWorkerView {
17101 instance_id: "worker-1".into(),
17102 entity_id: 42,
17103 def_id: "worker_laborer".into(),
17104 label: "Sam".into(),
17105 x,
17106 y,
17107 z: 0.0,
17108 mode: flatland_protocol::WorkerModeView::JobLoop,
17109 state: flatland_protocol::WorkerStateView::Working,
17110 step_label: "follow".into(),
17111 vitals: flatland_protocol::WorkerVitalsSummary {
17112 health_pct: 100.0,
17113 stamina_pct: 100.0,
17114 mana_pct: 100.0,
17115 hunger_pct: 100.0,
17116 thirst_pct: 100.0,
17117 },
17118 carry_pct: 0.0,
17119 last_error: None,
17120 wage_copper_per_interval: 1,
17121 effective_wage_copper: 1,
17122 wage_meters_walked: 0.0,
17123 lodging_container_id: None,
17124 route: None,
17125 route_stop_index: None,
17126 known_blueprint_ids: Vec::new(),
17127 level: 1,
17128 worker_xp: 0.0,
17129 inventory: Vec::new(),
17130 equipment: flatland_protocol::WorkerEquipmentView::default(),
17131 issue_hint: None,
17132 }
17133 }
17134
17135 #[test]
17136 fn probe_harvest_beats_closer_hired_worker() {
17137 let mut state = sample_state();
17138 state.resource_nodes[0].x = 129.0;
17139 state.resource_nodes[0].y = 128.0;
17140 state.hired_workers.push(sample_hired_worker(128.2, 128.0));
17141 let probe = state.probe_use_world();
17142 let primary = probe.primary.expect("primary");
17143 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17144 assert_eq!(primary.id, "oak-1");
17145 assert!(state.harvestable_node_in_range());
17146 assert_eq!(
17147 state.nearest_interact_target().as_deref(),
17148 Some("worker-1"),
17149 "harvest is not Interact — worker remains the interact target"
17150 );
17151 }
17152
17153 #[test]
17154 fn probe_door_beats_closer_hired_worker() {
17155 let mut state = sample_state();
17156 state.doors[0].x = 129.2;
17157 state.doors[0].y = 128.0;
17158 state.hired_workers.push(sample_hired_worker(128.3, 128.0));
17159 let probe = state.probe_use_world();
17160 let primary = probe.primary.expect("primary");
17161 assert!(
17162 matches!(
17163 primary.kind,
17164 crate::UseWorldKind::EnterDoor
17165 | crate::UseWorldKind::OpenDoor
17166 | crate::UseWorldKind::CloseDoor
17167 | crate::UseWorldKind::ExitDoor
17168 ),
17169 "door should win over closer worker, got {:?}",
17170 primary.kind
17171 );
17172 assert_eq!(primary.id, "door-1");
17173 assert_eq!(state.nearest_interact_target().as_deref(), Some("door-1"));
17174 }
17175
17176 #[test]
17177 fn probe_indoor_exit_door_beats_lodging_chest_pickup() {
17178 let mut state = sample_state();
17181 state.entities[0].inside_building = Some("player_house".into());
17182 state.entities[0].transform.position = WorldCoord::surface(5.0, 2.0);
17183 state.player = state.entities.first().cloned();
17184 state.buildings = vec![BuildingView {
17185 id: "player_house".into(),
17186 label: "MadSin's house".into(),
17187 x: 100.0,
17188 y: 100.0,
17189 width_m: 10.0,
17190 depth_m: 8.0,
17191 interior_blueprint: Some("player_house".into()),
17192 tags: vec!["player_built".into()],
17193 market_boundary_zone_ids: vec![],
17194 market_max_volume: None,
17195 wall_set: None,
17196 roof_set: None,
17197 }];
17198 state.doors = vec![flatland_protocol::DoorView {
17199 id: "house_exit".into(),
17200 building_id: "player_house".into(),
17201 x: 5.0,
17202 y: 1.0,
17203 open: true,
17204 portal: Some("front".into()),
17205 locked: false,
17206 accessible: true,
17207 lock_id: None,
17208 }];
17209 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17210 id: "lodging_bed".into(),
17211 template_id: "camp_bed".into(),
17212 display_name: "Camp bed".into(),
17213 x: 5.5,
17214 y: 2.4,
17215 z: 0.0,
17216 locked: false,
17217 accessible: true,
17218 owner_character_id: None,
17219 contents: vec![],
17220 lock_id: None,
17221 capacity_volume: None,
17222 item_instance_id: Some(uuid::Uuid::from_u128(99)),
17223 tile_id: None,
17224 worker_lodging_capacity: Some(1),
17225 blocking: false,
17226 blocking_radius_m: 0.0,
17227 building_id: Some("player_house".into()),
17228 }];
17229 let mut worker = sample_hired_worker(40.0, 40.0);
17231 worker.lodging_container_id = Some("lodging_bed".into());
17232 state.hired_workers.push(worker);
17233
17234 let probe = state.probe_use_world();
17235 let primary = probe.primary.expect("primary");
17236 assert!(
17237 matches!(
17238 primary.kind,
17239 crate::UseWorldKind::ExitDoor
17240 | crate::UseWorldKind::OpenDoor
17241 | crate::UseWorldKind::CloseDoor
17242 | crate::UseWorldKind::EnterDoor
17243 ),
17244 "indoor exit must beat lodging ChestPickup, got {:?}",
17245 primary.kind
17246 );
17247 assert_eq!(primary.id, "house_exit");
17248 assert_eq!(primary.kind.cascade_stage(), 0);
17249 assert!(
17250 probe
17251 .candidates
17252 .iter()
17253 .any(|c| c.kind == crate::UseWorldKind::ChestPickup && c.in_range),
17254 "lodging bed should still be an in-range chest candidate"
17255 );
17256 assert_eq!(
17257 state.nearest_interact_target().as_deref(),
17258 Some("house_exit"),
17259 "use_nearest interact path should target the door"
17260 );
17261 assert!(state.lodging_is_occupied("lodging_bed"));
17262 }
17263
17264 #[test]
17265 fn probe_worker_when_no_resource_or_door_in_range() {
17266 let mut state = sample_state();
17267 state.hired_workers.push(sample_hired_worker(129.0, 128.0));
17269 let probe = state.probe_use_world();
17270 let primary = probe.primary.expect("primary");
17271 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17272 assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17273 assert!(!state.harvestable_node_in_range());
17274 }
17275
17276 #[test]
17277 fn market_clerk_verb_options_include_market() {
17278 let mut state = sample_state();
17279 state.npcs.push(flatland_protocol::NpcView {
17280 id: "mira_market".into(),
17281 label: "Mira".into(),
17282 role: "market_clerk".into(),
17283 x: 129.0,
17284 y: 128.0,
17285 building_id: Some("town_market".into()),
17286 entity_id: None,
17287 life_state: None,
17288 hp_pct: None,
17289 can_trade: false,
17290 buy_templates: vec![],
17291 tile_id: None,
17292 behavior_state: None,
17293 presentation_state: None,
17294 sprite_mode: None,
17295 paperdoll_ref: None,
17296 draw_scale: 1.0,
17297 yaw: None,
17298 perception_fov_deg: None,
17299 perception_sight_m: None,
17300 perception_hear_m: None,
17301 quest_verbs: Vec::new(),
17302 });
17303 state.npc_verb_target = Some("mira_market".into());
17304 assert_eq!(
17305 state
17306 .npc_verb_options()
17307 .iter()
17308 .map(|v| v.label.as_str())
17309 .collect::<Vec<_>>(),
17310 vec!["Market", "Talk"]
17311 );
17312 }
17313
17314 #[test]
17315 fn butcher_verb_options_include_turn_in_for_give_item() {
17316 let mut state = sample_state();
17317 state.npcs.push(flatland_protocol::NpcView {
17318 id: "town_butcher_1".into(),
17319 label: "Brutus".into(),
17320 role: "butcher".into(),
17321 x: 129.0,
17322 y: 128.0,
17323 building_id: None,
17324 entity_id: None,
17325 life_state: None,
17326 hp_pct: None,
17327 can_trade: true,
17328 buy_templates: vec!["raw_venison".into()],
17329 tile_id: None,
17330 behavior_state: None,
17331 presentation_state: None,
17332 sprite_mode: None,
17333 paperdoll_ref: None,
17334 draw_scale: 1.0,
17335 yaw: None,
17336 perception_fov_deg: None,
17337 perception_sight_m: None,
17338 perception_hear_m: None,
17339 quest_verbs: Vec::new(),
17340 });
17341 state.quest_log.push(flatland_protocol::QuestLogEntry {
17342 quest_id: "deer_threat".into(),
17343 title: "Deer threat".into(),
17344 description: String::new(),
17345 status: flatland_protocol::QuestStatusView::Active,
17346 current_step_id: Some("deliver".into()),
17347 current_step_title: "Deliver venison".into(),
17348 current_step_index: 0,
17349 objectives: vec![flatland_protocol::QuestObjectiveProgress {
17350 label: "Give 3 Raw venison to Brutus".into(),
17351 current: 0,
17352 required: 3,
17353 done: false,
17354 kind: "give_item".into(),
17355 npc_ref: Some("town_butcher_1".into()),
17356 item_template: Some("raw_venison".into()),
17357 blueprint_id: None,
17358 building_id: None,
17359 }],
17360 current_step_reward: flatland_protocol::QuestRewardView::default(),
17361 completion_reward: flatland_protocol::QuestRewardView::default(),
17362 steps: Vec::new(),
17363 is_tracked: true,
17364 can_withdraw: true,
17365 });
17366 state.npc_verb_target = Some("town_butcher_1".into());
17367 assert_eq!(
17368 state
17369 .npc_verb_options()
17370 .iter()
17371 .map(|v| v.label.as_str())
17372 .collect::<Vec<_>>(),
17373 vec!["Turn in: Deer threat", "Talk", "Trade"]
17374 );
17375 }
17376
17377 #[test]
17378 fn ada_verb_options_include_quest_offer() {
17379 let mut state = sample_state();
17380 state.npcs.push(flatland_protocol::NpcView {
17381 id: "ada_broker".into(),
17382 label: "Ada".into(),
17383 role: "broker".into(),
17384 x: 129.0,
17385 y: 128.0,
17386 building_id: None,
17387 entity_id: None,
17388 life_state: None,
17389 hp_pct: None,
17390 can_trade: true,
17391 buy_templates: vec![],
17392 tile_id: None,
17393 behavior_state: None,
17394 presentation_state: None,
17395 sprite_mode: None,
17396 paperdoll_ref: Some("ada_broker".into()),
17397 draw_scale: 1.0,
17398 yaw: None,
17399 perception_fov_deg: None,
17400 perception_sight_m: None,
17401 perception_hear_m: None,
17402 quest_verbs: vec![flatland_protocol::NpcQuestVerb {
17403 quest_id: "ada_goblin_hunt".into(),
17404 label: "Ask about goblins".into(),
17405 kind: flatland_protocol::NpcQuestVerb::KIND_OFFER.into(),
17406 }],
17407 });
17408 state.npc_verb_target = Some("ada_broker".into());
17409 assert_eq!(
17410 state
17411 .npc_verb_options()
17412 .iter()
17413 .map(|v| v.label.as_str())
17414 .collect::<Vec<_>>(),
17415 vec!["Ask about goblins", "Talk", "Trade"]
17416 );
17417 }
17418
17419 #[test]
17420 fn market_list_excludes_currency_stacks() {
17421 let mut state = sample_state();
17422 state.inventory_stacks = vec![
17423 flatland_protocol::ItemStack {
17424 template_id: "copper_coin".into(),
17425 quantity: 50,
17426 item_instance_id: Some(uuid::Uuid::from_u128(10)),
17427 display_name: Some("Copper Coin".into()),
17428 ..Default::default()
17429 },
17430 flatland_protocol::ItemStack {
17431 template_id: "oak_log".into(),
17432 quantity: 2,
17433 item_instance_id: Some(uuid::Uuid::from_u128(11)),
17434 display_name: Some("Oak Log".into()),
17435 ..Default::default()
17436 },
17437 flatland_protocol::ItemStack {
17438 template_id: "whisper_stone".into(),
17439 quantity: 1,
17440 item_instance_id: Some(uuid::Uuid::from_u128(12)),
17441 display_name: Some("Whisper Stone".into()),
17442 category: Some("quest".into()),
17443 listable: Some(false),
17444 ..Default::default()
17445 },
17446 ];
17447 let opts = state.market_list_item_options(&MarketListSourceKind::Person);
17448 assert_eq!(opts.len(), 1);
17449 assert!(opts[0].label.contains("Oak"));
17450 }
17451
17452 #[test]
17453 fn market_browse_filters_by_category_and_search() {
17454 let mut state = sample_state();
17455 state.market_panel = Some(flatland_protocol::MarketPanel {
17456 npc_id: "mira_market".into(),
17457 npc_label: "Mira".into(),
17458 building_id: "town_market".into(),
17459 building_label: "Town Market".into(),
17460 used_volume: 0.0,
17461 max_volume: 100.0,
17462 listings: vec![
17463 flatland_protocol::MarketListingView {
17464 listing_id: uuid::Uuid::from_u128(1),
17465 seller_character_id: uuid::Uuid::from_u128(2),
17466 seller_label: "Ada".into(),
17467 hall_building_id: "town_market".into(),
17468 hall_label: "Town Market".into(),
17469 template_id: "oak_log".into(),
17470 display_name: "Oak Log".into(),
17471 category: "resource".into(),
17472 quantity: 3,
17473 unit_price_copper: 10,
17474 line_total_copper: 30,
17475 npc_price: false,
17476 npc_dump_unit_copper: None,
17477 mine: false,
17478 },
17479 flatland_protocol::MarketListingView {
17480 listing_id: uuid::Uuid::from_u128(3),
17481 seller_character_id: uuid::Uuid::from_u128(2),
17482 seller_label: "Ada".into(),
17483 hall_building_id: "town_market".into(),
17484 hall_label: "Town Market".into(),
17485 template_id: "short_sword".into(),
17486 display_name: "Short Sword".into(),
17487 category: "weapon".into(),
17488 quantity: 1,
17489 unit_price_copper: 100,
17490 line_total_copper: 100,
17491 npc_price: false,
17492 npc_dump_unit_copper: None,
17493 mine: false,
17494 },
17495 ],
17496 tax_bps: 0,
17497 tax_flat_copper: 0,
17498 list_vaults: vec![],
17499 });
17500 assert_eq!(state.market_filtered_listing_indices().len(), 2);
17501 state.market_category_filter = Some("Weapons");
17502 let weapons = state.market_filtered_listing_indices();
17503 assert_eq!(weapons.len(), 1);
17504 assert_eq!(
17505 state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
17506 "Short Sword"
17507 );
17508 state.market_category_filter = None;
17509 state.market_filter = "oak".into();
17510 let oak = state.market_filtered_listing_indices();
17511 assert_eq!(oak.len(), 1);
17512 assert_eq!(
17513 state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
17514 "Oak Log"
17515 );
17516 }
17517
17518 #[test]
17519 fn market_list_source_includes_person_and_vaults() {
17520 let mut state = sample_state();
17521 let item_id = uuid::Uuid::from_u128(1);
17522 state.inventory_stacks = vec![flatland_protocol::ItemStack {
17523 template_id: "oak_log".into(),
17524 quantity: 2,
17525 item_instance_id: Some(item_id),
17526 display_name: Some("Oak Log".into()),
17527 ..Default::default()
17528 }];
17529 state.market_panel = Some(flatland_protocol::MarketPanel {
17530 npc_id: "mira_market".into(),
17531 npc_label: "Mira".into(),
17532 building_id: "town_market".into(),
17533 building_label: "Town Market".into(),
17534 used_volume: 0.0,
17535 max_volume: 100.0,
17536 listings: vec![],
17537 tax_bps: 0,
17538 tax_flat_copper: 0,
17539 list_vaults: vec![flatland_protocol::MarketListVault {
17540 building_id: "town_storage".into(),
17541 building_label: "Town Storage".into(),
17542 contents: vec![flatland_protocol::ItemStack {
17543 template_id: "lumber".into(),
17544 quantity: 1,
17545 item_instance_id: Some(uuid::Uuid::from_u128(2)),
17546 display_name: Some("Lumber".into()),
17547 ..Default::default()
17548 }],
17549 }],
17550 });
17551 let sources = state.market_list_source_options();
17552 assert_eq!(sources.len(), 2);
17553 assert!(matches!(sources[0].0, MarketListSourceKind::Person));
17554 assert!(matches!(
17555 sources[1].0,
17556 MarketListSourceKind::TownStorage { .. }
17557 ));
17558 assert!(sources[1].1.contains("Town Storage"));
17559 }
17560
17561 #[test]
17562 fn npc_market_dump_estimate_from_town_storage_vault() {
17563 let mut state = sample_state();
17564 state.market_panel = Some(flatland_protocol::MarketPanel {
17565 npc_id: "mira_market".into(),
17566 npc_label: "Mira".into(),
17567 building_id: "town_market".into(),
17568 building_label: "Town Market".into(),
17569 used_volume: 0.0,
17570 max_volume: 100.0,
17571 listings: vec![],
17572 tax_bps: 0,
17573 tax_flat_copper: 0,
17574 list_vaults: vec![flatland_protocol::MarketListVault {
17575 building_id: "town_storage".into(),
17576 building_label: "Town Storage".into(),
17577 contents: vec![flatland_protocol::ItemStack {
17578 template_id: "lumber".into(),
17579 quantity: 3,
17580 item_instance_id: Some(uuid::Uuid::from_u128(2)),
17581 display_name: Some("Lumber".into()),
17582 base_value_copper: Some(20),
17583 ..Default::default()
17584 }],
17585 }],
17586 });
17587 assert_eq!(
17588 state.npc_market_dump_unit_estimate("lumber"),
17589 Some(9),
17590 "vault stack base_value should enable NPC price estimate"
17591 );
17592 }
17593
17594 #[test]
17595 fn probe_use_world_npc_beats_nearby_loot() {
17596 let mut state = sample_state();
17597 state.npcs.push(flatland_protocol::NpcView {
17598 id: "ada".into(),
17599 label: "Ada".into(),
17600 role: "broker".into(),
17601 x: 129.0,
17602 y: 128.0,
17603 building_id: None,
17604 entity_id: None,
17605 life_state: None,
17606 hp_pct: None,
17607 can_trade: true,
17608 buy_templates: vec!["lumber".into()],
17609 tile_id: None,
17610 behavior_state: None,
17611 presentation_state: None,
17612 sprite_mode: None,
17613 paperdoll_ref: None,
17614 draw_scale: 1.0,
17615 yaw: None,
17616 perception_fov_deg: None,
17617 perception_sight_m: None,
17618 perception_hear_m: None,
17619 quest_verbs: Vec::new(),
17620 });
17621 state.ground_drops.push(flatland_protocol::GroundDropView {
17622 id: "d1".into(),
17623 template_id: "lumber".into(),
17624 quantity: 1,
17625 x: 128.5,
17626 y: 128.0,
17627 z: 0.0,
17628 tile_id: None,
17629 display_name: None,
17630 yaw: 0.0,
17631 pitch: 0.0,
17632 roll: 0.0,
17633 draw_scale: 1.0,
17634 item_instance_id: None,
17635 props: Default::default(),
17636 status_bindings: Vec::new(),
17637 });
17638 let probe = state.probe_use_world();
17639 let primary = probe.primary.expect("primary");
17640 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
17641 assert_eq!(primary.id, "ada");
17642 }
17643
17644 #[test]
17645 fn probe_use_world_harvest_when_in_range() {
17646 let state = sample_state(); let probe = state.probe_use_world();
17648 assert!(
17649 probe.primary.is_none(),
17650 "oak is 2m away, out of harvest range"
17651 );
17652 assert!(probe
17653 .candidates
17654 .iter()
17655 .any(|c| c.kind == crate::UseWorldKind::Harvest));
17656
17657 let mut state = sample_state();
17658 state.resource_nodes[0].x = 129.0;
17659 let probe = state.probe_use_world();
17660 let primary = probe.primary.expect("primary");
17661 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17662 }
17663
17664 #[test]
17665 fn probe_use_world_door_uses_building_label() {
17666 let mut state = sample_state();
17667 state.doors[0].x = 129.0;
17668 state.doors[0].y = 128.0;
17669 let probe = state.probe_use_world();
17670 let primary = probe.primary.expect("primary");
17671 assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
17672 assert_eq!(primary.label, "Broker");
17673 assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
17674 }
17675
17676 #[test]
17677 fn empty_entity_tick_preserves_welcome_snapshot() {
17678 let mut state = sample_state();
17679 state.inventory.insert("carrot".into(), 3);
17680 let delta = TickDelta {
17681 tick: 1,
17682 entities: vec![],
17683 resource_nodes: vec![],
17684 ground_drops: vec![],
17685 placed_containers: vec![],
17686 buildings: vec![],
17687 doors: vec![],
17688 interior_map: None,
17689 npcs: vec![],
17690 inventory: vec![],
17691 blueprints: vec![],
17692 building_materials: vec![],
17693 world_clock: flatland_protocol::WorldClock::default(),
17694 combat: None,
17695 quest_log: vec![],
17696 hired_workers: Vec::new(),
17697 interactables: vec![],
17698 ledger: None,
17699 career: None,
17700 combat_fx: Vec::new(),
17701 ground_hazards: Vec::new(),
17702 property_plots: Vec::new(),
17703 terrain_overlays: Vec::new(),
17704 };
17705
17706 state.apply_tick_fields(&delta, 1);
17707
17708 assert_eq!(state.entities.len(), 1);
17709 assert!(state.player.is_some());
17710 assert_eq!(state.inventory.get("carrot"), Some(&3));
17711 assert_eq!(state.resource_nodes.len(), 1);
17712 }
17713
17714 #[test]
17715 fn tick_preserves_world_layers_when_delta_omits_them() {
17716 let mut state = sample_state();
17717 let delta = TickDelta {
17718 tick: 1,
17719 entities: state.entities.clone(),
17720 resource_nodes: vec![],
17721 ground_drops: vec![],
17722 placed_containers: vec![],
17723 buildings: vec![],
17724 doors: vec![],
17725 interior_map: None,
17726 npcs: vec![],
17727 inventory: vec![],
17728 blueprints: vec![],
17729 building_materials: vec![],
17730 world_clock: flatland_protocol::WorldClock::default(),
17731 combat: None,
17732 quest_log: vec![],
17733 hired_workers: Vec::new(),
17734 interactables: vec![],
17735 ledger: None,
17736 career: None,
17737 combat_fx: Vec::new(),
17738 ground_hazards: Vec::new(),
17739 property_plots: Vec::new(),
17740 terrain_overlays: Vec::new(),
17741 };
17742
17743 state.apply_tick_fields(&delta, 1);
17744
17745 assert_eq!(state.resource_nodes.len(), 1);
17746 assert_eq!(state.buildings.len(), 1);
17747 assert_eq!(state.doors.len(), 1);
17748 }
17749
17750 #[test]
17751 fn tick_updates_resource_nodes_when_server_sends_them() {
17752 let mut state = sample_state();
17753 let delta = TickDelta {
17754 tick: 1,
17755 entities: state.entities.clone(),
17756 resource_nodes: vec![ResourceNodeView {
17757 id: "oak-1".into(),
17758 label: "Oak".into(),
17759 x: 130.0,
17760 y: 128.0,
17761 z: 0.0,
17762 item_template: "oak_log".into(),
17763 state: ResourceNodeState::Cooldown,
17764 blocking: true,
17765 blocking_radius_m: 0.8,
17766 harvest_off: false,
17767 tile_id: None,
17768 yaw: 0.0,
17769 pitch: 0.0,
17770 roll: 0.0,
17771 draw_scale: 1.0,
17772 sprite_mode: None,
17773 growth_progress: None,
17774 presentation_state: None,
17775 channel_start_tick: None,
17776 channel_end_tick: None,
17777 harvest_drop_templates: vec![],
17778 }],
17779 buildings: vec![],
17780 doors: vec![],
17781 interior_map: None,
17782 npcs: vec![],
17783 inventory: vec![],
17784 blueprints: vec![],
17785 building_materials: vec![],
17786 world_clock: flatland_protocol::WorldClock::default(),
17787 ground_drops: vec![],
17788 placed_containers: vec![],
17789 combat: None,
17790 quest_log: vec![],
17791 hired_workers: Vec::new(),
17792 interactables: vec![],
17793 ledger: None,
17794 career: None,
17795 combat_fx: Vec::new(),
17796 ground_hazards: Vec::new(),
17797 property_plots: Vec::new(),
17798 terrain_overlays: Vec::new(),
17799 };
17800
17801 state.apply_tick_fields(&delta, 1);
17802
17803 assert!(matches!(
17804 state.resource_nodes[0].state,
17805 ResourceNodeState::Cooldown
17806 ));
17807 }
17808
17809 #[test]
17810 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
17811 let mut state = GameState {
17812 session_id: 1,
17813 entity_id: 1,
17814 character_id: None,
17815 tick: 0,
17816 chunk_rev: 0,
17817 content_rev: 0,
17818 publish_rev: 0,
17819 entities: vec![EntityState {
17820 id: 1,
17821 label: "You".into(),
17822 transform: Transform {
17823 position: WorldCoord::surface(4.5, 2.0),
17824 yaw: 0.0,
17825 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
17826 },
17827 vitals: None,
17828 attributes: None,
17829 skills: None,
17830 inside_building: Some("broker_hut".into()),
17831 tile_id: None,
17832 paperdoll_ref: None,
17833 draw_scale: 1.0,
17834 presentation_state: None,
17835 sprite_mode: None,
17836 progression_xp: None,
17837 combat_cues: vec![],
17838 statuses: vec![],
17839 }],
17840 player: None,
17841 resource_nodes: vec![],
17842 ground_drops: vec![],
17843 placed_containers: vec![],
17844 buildings: vec![BuildingView {
17845 id: "broker_hut".into(),
17846 label: "Broker".into(),
17847 x: 158.0,
17848 y: 124.0,
17849 width_m: 8.0,
17850 depth_m: 6.0,
17851 interior_blueprint: Some("broker_hut".into()),
17852 tags: vec![],
17853 market_boundary_zone_ids: vec![],
17854 market_max_volume: None,
17855 wall_set: None,
17856 roof_set: None,
17857 }],
17858 doors: vec![flatland_protocol::DoorView {
17859 id: "broker_hut_exit".into(),
17860 building_id: "broker_hut".into(),
17861 x: 4.3,
17862 y: 0.9,
17863 open: true,
17864 portal: Some("front".into()),
17865 locked: false,
17866 accessible: true,
17867 lock_id: None,
17868 }],
17869 interior_map: None,
17870 npcs: vec![flatland_protocol::NpcView {
17871 id: "ada_broker".into(),
17872 label: "Ada".into(),
17873 x: 4.5,
17874 y: 2.0,
17875 building_id: Some("broker_hut".into()),
17876 role: "broker".into(),
17877 entity_id: None,
17878 life_state: None,
17879 hp_pct: None,
17880 can_trade: true,
17881 buy_templates: vec!["lumber".into()],
17882 tile_id: None,
17883 behavior_state: None,
17884 presentation_state: None,
17885 sprite_mode: None,
17886 paperdoll_ref: None,
17887 draw_scale: 1.0,
17888 yaw: None,
17889 perception_fov_deg: None,
17890 perception_sight_m: None,
17891 perception_hear_m: None,
17892 quest_verbs: Vec::new(),
17893 }],
17894 blueprints: vec![],
17895 building_materials: vec![],
17896 world_x0: 0.0,
17897 world_y0: 0.0,
17898 world_width_m: 256.0,
17899 world_height_m: 256.0,
17900 terrain_zones: Vec::new(),
17901 z_platforms: Vec::new(),
17902 z_transitions: Vec::new(),
17903 z_bands_outdoor_backup: None,
17904 world_clock: flatland_protocol::WorldClock::default(),
17905 inventory: std::collections::HashMap::new(),
17906 inventory_hints: std::collections::HashMap::new(),
17907 item_catalog: std::collections::HashMap::new(),
17908 logs: VecDeque::new(),
17909 intents_sent: 0,
17910 ticks_received: 0,
17911 connected: true,
17912 disconnect_reason: None,
17913 show_stats: false,
17914 hud_log_hidden: false,
17915 show_equip_menu: false,
17916 equip_menu_index: 0,
17917 show_craft_menu: false,
17918 show_plot_build_menu: false,
17919 plot_build_focus_wall: true,
17920 plot_build_wall_index: 0,
17921 plot_build_roof_index: 0,
17922 craft_menu_index: 0,
17923 craft_batch_quantity: 1,
17924 craft_tab: CraftTab::Ready,
17925 craft_filter: String::new(),
17926 craft_filter_focused: false,
17927 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
17928 show_shop_menu: false,
17929 shop_catalog: None,
17930 bank_panel: None,
17931 bank_menu_index: 0,
17932 bank_ui_mode: BankUiMode::Menu,
17933 storage_panel: None,
17934 market_panel: None,
17935 market_menu_index: 0,
17936 market_filter: String::new(),
17937 market_filter_focused: false,
17938 market_category_filter: None,
17939 market_buy_confirm: None,
17940 market_ui_mode: MarketUiMode::Browse,
17941 storage_menu_index: 0,
17942 storage_ui_mode: StorageUiMode::Menu,
17943 shop_tab: ShopTab::default(),
17944 shop_menu_index: 0,
17945 shop_quantity: 1,
17946 shop_trade_log: VecDeque::new(),
17947 show_npc_verb_menu: false,
17948 npc_verb_target: None,
17949 npc_verb_index: 0,
17950 npc_verb_notice: None,
17951 player_verbs: crate::social::PlayerVerbState::default(),
17952 social_chat: crate::social::SocialChatState::default(),
17953 trade_ui: crate::social::TradeUiState::default(),
17954 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
17955 show_npc_chat: false,
17956 npc_chat: None,
17957 show_inventory_menu: false,
17958 inventory_menu_index: 0,
17959 inventory_tab: InventoryTab::OnPerson,
17960 inventory_filter: String::new(),
17961 inventory_filter_focused: false,
17962 show_move_picker: false,
17963 show_rename_prompt: false,
17964 rename_plot_id: None,
17965 highlighted_plot_id: None,
17966 show_worker_rename: false,
17967 rename_buffer: String::new(),
17968 move_picker_index: 0,
17969 move_picker: None,
17970 show_grant_picker: false,
17971 grant_picker_index: 0,
17972 grant_picker: None,
17973 show_destroy_picker: false,
17974 destroy_confirm_pending: false,
17975 destroy_picker: None,
17976 combat_target: None,
17977 combat_target_label: None,
17978 ground_target: None,
17979 combat_fx: Vec::new(),
17980 ground_hazards: Vec::new(),
17981 property_zones: Vec::new(),
17982 tax_zones: Vec::new(),
17983 growth_zones: Vec::new(),
17984 biome_zones: Vec::new(),
17985 terrain_kind_nav: Vec::new(),
17986 property_plots: Vec::new(),
17987 property_plot_settings: None,
17988 claim_mode: None,
17989 relocate_mode: None,
17990 sell_plot_confirm: None,
17991 sell_plot_armed_at: None,
17992 show_plant_menu: false,
17993 plant_menu_index: 0,
17994 show_farm_access: false,
17995 farm_access_name_draft: String::new(),
17996 farm_access_discount_bps: 0,
17997 farm_access_index: 0,
17998 plant_quantity: 1,
17999 in_combat: false,
18000 auto_attack: true,
18001 combat_has_los: false,
18002 attack_cd_ticks: 0,
18003 gcd_ticks: 0,
18004 weapon_ability_id: "unarmed".into(),
18005 mainhand_template_id: None,
18006 mainhand_label: None,
18007 mainhand_instance_id: None,
18008 offhand_template_id: None,
18009 offhand_label: None,
18010 offhand_instance_id: None,
18011 mainhand_hand_slots: 1,
18012 defense: None,
18013 worn: BTreeMap::new(),
18014 carry_mass: 0.0,
18015 carry_mass_max: 0.0,
18016 encumbrance: flatland_protocol::EncumbranceState::Light,
18017 move_speed_mps: 0.0,
18018 move_speed_mult: 0.0,
18019 inventory_stacks: Vec::new(),
18020 keychain_stacks: Vec::new(),
18021 whisper_pouch_stacks: Vec::new(),
18022 combat_target_detail: None,
18023 statuses: Vec::new(),
18024 cast_progress: None,
18025 timed_channel: None,
18026 plot_build_offer: None,
18027 ability_cooldowns: Vec::new(),
18028 blocking_active: false,
18029 max_target_slots: 1,
18030 combat_slots: Vec::new(),
18031 rotation_presets: Vec::new(),
18032 known_abilities: Vec::new(),
18033 ability_meta: std::collections::HashMap::new(),
18034 ability_mastery: std::collections::HashMap::new(),
18035 hotbar: vec![None; 9],
18036 max_abilities_per_rotation: 0,
18037 show_loadout_menu: false,
18038 show_keychain_menu: false,
18039 keychain_menu_index: 0,
18040 show_rotation_editor: false,
18041 loadout_menu_index: 0,
18042 loadout_hotbar_slot: 1,
18043 loadout_ability_index: 0,
18044 loadout_focus_presets: false,
18045 rotation_editor: RotationEditorState::default(),
18046 harvest_in_progress: false,
18047 harvest_started_at: None,
18048 pending_craft_ack: None,
18049 craft_channel_blueprint_id: None,
18050 pending_worker_job_ack: None,
18051 attending_worker_instance_id: None,
18052 quest_log: Vec::new(),
18053 interactables: Vec::new(),
18054 ledger: None,
18055 career: None,
18056 character_sheet_tab: CharacterSheetTab::Character,
18057 ledger_period: LedgerPeriod::Day,
18058 show_quest_offer: false,
18059 pending_quest_offers: Vec::new(),
18060 quest_offer_index: 0,
18061 show_quest_menu: false,
18062 quest_menu_index: 0,
18063 quest_withdraw_confirm: false,
18064 hired_workers: Vec::new(),
18065 show_workers_menu: false,
18066 workers_menu_index: 0,
18067 worker_dismiss_confirmation: None,
18068 workers_menu_compact: false,
18069 worker_step_display: BTreeMap::new(),
18070 worker_error_display: BTreeMap::new(),
18071 worker_health_ring_until: BTreeMap::new(),
18072 pending_worker_hire_since: None,
18073 show_worker_give_picker: false,
18074 worker_give_picker_index: 0,
18075 worker_give_picker: None,
18076 show_worker_give_target_picker: false,
18077 worker_give_target_picker_index: 0,
18078 worker_give_target_picker: None,
18079 show_worker_take_picker: false,
18080 worker_take_picker_index: 0,
18081 worker_take_picker: None,
18082 show_worker_teach_picker: false,
18083 worker_teach_picker_index: 0,
18084 worker_teach_picker: None,
18085 worker_route_editor: None,
18086 progression_curve: None,
18087 };
18088 state.player = state.entities.first().cloned();
18089 assert_eq!(
18090 state.nearest_interact_target().as_deref(),
18091 Some("ada_broker")
18092 );
18093 }
18094
18095 #[test]
18096 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
18097 let mut state = sample_state();
18098 state.placed_containers = vec![
18101 flatland_protocol::PlacedContainerView {
18102 id: "near".into(),
18103 template_id: "wooden_chest_small".into(),
18104 display_name: "Wooden Chest".into(),
18105 x: 130.0,
18106 y: 128.0,
18107 z: 0.0,
18108 locked: true,
18109 accessible: true,
18110 owner_character_id: None,
18111 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
18112 lock_id: None,
18113 capacity_volume: None,
18114 item_instance_id: Some(uuid::Uuid::from_u128(1)),
18115 tile_id: None,
18116 worker_lodging_capacity: None,
18117 blocking: false,
18118 blocking_radius_m: 0.0,
18119 building_id: None,
18120 },
18121 flatland_protocol::PlacedContainerView {
18122 id: "far".into(),
18123 template_id: "wooden_chest_small".into(),
18124 display_name: "Distant Chest".into(),
18125 x: 128.0 + CONTAINER_RANGE_M + 5.0,
18126 y: 128.0,
18127 z: 0.0,
18128 locked: false,
18129 accessible: true,
18130 owner_character_id: None,
18131 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
18132 lock_id: None,
18133 capacity_volume: None,
18134 item_instance_id: Some(uuid::Uuid::from_u128(2)),
18135 tile_id: None,
18136 worker_lodging_capacity: None,
18137 blocking: false,
18138 blocking_radius_m: 0.0,
18139 building_id: None,
18140 },
18141 ];
18142
18143 let nearby = state.nearby_containers();
18144 assert_eq!(
18145 nearby.len(),
18146 1,
18147 "far chest must not appear once out of range"
18148 );
18149 assert_eq!(nearby[0].view.id, "near");
18150 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
18151 assert!(nearby[0].rows[0].is_chest_shell);
18152
18153 state.placed_containers[0].accessible = false;
18156 let nearby = state.nearby_containers();
18157 assert_eq!(nearby.len(), 1);
18158 assert_eq!(nearby[0].rows.len(), 1);
18159 assert!(nearby[0].rows[0].is_chest_shell);
18160 }
18161
18162 #[test]
18163 fn chest_pickup_destinations_offer_person_and_worn_bag() {
18164 let mut state = sample_state();
18165 let back_id = uuid::Uuid::from_u128(42);
18166 state.worn.insert(
18167 BodySlot::Back,
18168 flatland_protocol::ItemStack {
18169 template_id: "travel_backpack".into(),
18170 quantity: 1,
18171 item_instance_id: Some(back_id),
18172 props: Default::default(),
18173 status_bindings: Vec::new(),
18174 contents: Vec::new(),
18175 display_name: Some("Travel Backpack".into()),
18176 category: Some("container".into()),
18177 base_mass: Some(2.5),
18178 base_volume: Some(12.0),
18179 capacity_volume: Some(80.0),
18180 stackable: Some(false),
18181 world_placeable: Some(false),
18182 worker_lodging_capacity: None,
18183 equip_slot: None,
18184 armor_physical: None,
18185 resists: vec![],
18186 hand_slots: None,
18187 listable: None,
18188 ..Default::default()
18189 },
18190 );
18191 let opts = state.chest_pickup_destinations("chest-1");
18192 assert!(matches!(
18193 opts.first().map(|o| &o.kind),
18194 Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
18195 ));
18196 assert!(opts.iter().any(|o| matches!(
18197 &o.kind,
18198 MoveOptionKind::PickupPlaced {
18199 nest_parent_instance_id: None,
18200 ..
18201 }
18202 )));
18203 assert!(opts.iter().any(|o| matches!(
18204 &o.kind,
18205 MoveOptionKind::PickupPlaced {
18206 nest_parent_instance_id: Some(id),
18207 ..
18208 } if *id == back_id
18209 )));
18210 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18211 }
18212
18213 #[test]
18214 fn placed_container_public_label_hides_owner_custom_name() {
18215 let owner = uuid::Uuid::from_u128(99);
18216 let mut state = sample_state();
18217 state.character_id = Some(uuid::Uuid::from_u128(1));
18218 state.inventory_hints.insert(
18219 "wooden_chest_medium".into(),
18220 InventoryHint {
18221 display_name: "Medium Wooden Chest".into(),
18222 category: "container".into(),
18223 base_mass: None,
18224 base_volume: None,
18225 capacity_volume: None,
18226 stackable: false,
18227 listable: true,
18228 base_value_copper: None,
18229 },
18230 );
18231 let chest = flatland_protocol::PlacedContainerView {
18232 id: "c1".into(),
18233 template_id: "wooden_chest_medium".into(),
18234 display_name: "Barry's Loot #a3f2".into(),
18235 x: 128.0,
18236 y: 128.0,
18237 z: 0.0,
18238 locked: false,
18239 accessible: true,
18240 owner_character_id: Some(owner),
18241 contents: vec![],
18242 lock_id: None,
18243 capacity_volume: None,
18244 item_instance_id: None,
18245 tile_id: None,
18246 worker_lodging_capacity: None,
18247 blocking: false,
18248 blocking_radius_m: 0.0,
18249 building_id: None,
18250 };
18251 assert_eq!(
18252 state.placed_container_public_label(&chest),
18253 "Medium Wooden Chest"
18254 );
18255 state.character_id = Some(owner);
18256 assert_eq!(
18257 state.placed_container_public_label(&chest),
18258 "Barry's Loot #a3f2"
18259 );
18260 }
18261
18262 #[test]
18263 fn location_context_shows_crop_growth_percent_not_depleted() {
18264 let mut state = sample_state();
18265 state.player = state.entities.first().cloned();
18266 state.resource_nodes[0].label = "Carrot (growing)".into();
18267 state.resource_nodes[0].x = 128.2;
18268 state.resource_nodes[0].y = 128.0;
18269 state.resource_nodes[0].state = ResourceNodeState::Cooldown;
18270 state.resource_nodes[0].growth_progress = Some(0.47);
18271 let lines = state.location_context_lines();
18272 let line = lines
18273 .iter()
18274 .find(|l| l.text.contains("Carrot"))
18275 .map(|l| l.text.as_str())
18276 .unwrap_or("");
18277 assert!(
18278 line.contains("(growing, 47%)"),
18279 "expected growth percent, got: {line}"
18280 );
18281 assert!(
18282 !line.contains("depleted"),
18283 "growing crop should not show depleted: {line}"
18284 );
18285 }
18286
18287 #[test]
18288 fn resource_node_near_action_suffix_prefers_growth() {
18289 let node = ResourceNodeView {
18290 id: "crop".into(),
18291 label: "Wheat".into(),
18292 x: 0.0,
18293 y: 0.0,
18294 z: 0.0,
18295 item_template: "wheat".into(),
18296 state: ResourceNodeState::Cooldown,
18297 blocking: false,
18298 blocking_radius_m: 0.0,
18299 harvest_off: false,
18300 tile_id: None,
18301 yaw: 0.0,
18302 pitch: 0.0,
18303 roll: 0.0,
18304 draw_scale: 1.0,
18305 sprite_mode: None,
18306 growth_progress: Some(0.12),
18307 presentation_state: None,
18308 channel_start_tick: None,
18309 channel_end_tick: None,
18310 harvest_drop_templates: vec![],
18311 };
18312 assert_eq!(resource_node_near_action_suffix(&node), " (growing, 12%)");
18313 }
18314
18315 #[test]
18316 fn location_context_lists_nearby_resource_node() {
18317 let mut state = sample_state();
18318 state.player = state.entities.first().cloned();
18319 state.resource_nodes[0].x = 128.2;
18320 state.resource_nodes[0].y = 128.0;
18321 let lines = state.location_context_lines();
18322 assert!(
18323 lines
18324 .iter()
18325 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
18326 "expected resource node in context: {:?}",
18327 lines
18328 );
18329 }
18330
18331 #[test]
18332 fn quest_board_usable_within_board_radius() {
18333 let mut state = sample_state();
18334 state.player = state.entities.first().cloned();
18335 state.interactables = vec![flatland_protocol::InteractableView {
18336 id: "board-1".into(),
18337 kind: "quest_board".into(),
18338 label: "Town Quest Board".into(),
18339 x: 130.5,
18340 y: 128.0,
18341 z: 0.0,
18342 board_id: Some("starter_town_board".into()),
18343 }];
18344 assert_eq!(
18346 state.nearest_interact_target().as_deref(),
18347 Some("board-1"),
18348 "quest board should be selectable at ~2.5m"
18349 );
18350 let lines = state.location_context_lines();
18351 assert!(
18352 lines
18353 .iter()
18354 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
18355 "HUD should advertise f when board is in range: {:?}",
18356 lines
18357 );
18358 }
18359
18360 #[test]
18361 fn quest_board_keeps_multiple_offers() {
18362 let mut state = sample_state();
18363 let offer = |id: &str, title: &str| flatland_protocol::QuestOffer {
18364 quest_id: id.into(),
18365 title: title.into(),
18366 description: format!("{title} desc"),
18367 step_count: 2,
18368 };
18369 state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18370 state.push_quest_offer(offer("daily_20695_1", "Town board: Deer threat"));
18371 state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18372 assert_eq!(state.pending_quest_offers.len(), 2);
18373 assert_eq!(
18374 state.selected_quest_offer().unwrap().quest_id,
18375 "ada_goblin_hunt"
18376 );
18377 state.move_quest_offer_selection(1);
18378 assert_eq!(
18379 state.selected_quest_offer().unwrap().quest_id,
18380 "daily_20695_1"
18381 );
18382 state.remove_quest_offer("daily_20695_1");
18383 assert_eq!(state.pending_quest_offers.len(), 1);
18384 assert!(state.show_quest_offer);
18385 state.remove_quest_offer("ada_goblin_hunt");
18386 assert!(!state.show_quest_offer);
18387 assert!(state.pending_quest_offers.is_empty());
18388 }
18389
18390 #[test]
18391 fn inventory_selectable_rows_excludes_equipped_shells_but_keeps_bag_contents() {
18392 let mut state = sample_state();
18393 state.worn.insert(
18394 BodySlot::Back,
18395 flatland_protocol::ItemStack {
18396 template_id: "travel_backpack".into(),
18397 quantity: 1,
18398 item_instance_id: Some(uuid::Uuid::from_u128(3)),
18399 props: Default::default(),
18400 status_bindings: Vec::new(),
18401 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
18402 display_name: None,
18403 category: None,
18404 base_mass: None,
18405 base_volume: None,
18406 capacity_volume: None,
18407 stackable: None,
18408 world_placeable: None,
18409 worker_lodging_capacity: None,
18410 equip_slot: None,
18411 armor_physical: None,
18412 resists: vec![],
18413 hand_slots: None,
18414 listable: None,
18415 ..Default::default()
18416 },
18417 );
18418 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
18419 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18420 id: "chest-1".into(),
18421 template_id: "wooden_chest_small".into(),
18422 display_name: "Wooden Chest".into(),
18423 x: 129.0,
18424 y: 128.0,
18425 z: 0.0,
18426 locked: false,
18427 accessible: true,
18428 owner_character_id: None,
18429 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
18430 lock_id: None,
18431 capacity_volume: None,
18432 item_instance_id: Some(uuid::Uuid::from_u128(4)),
18433 tile_id: None,
18434 worker_lodging_capacity: None,
18435 blocking: false,
18436 blocking_radius_m: 0.0,
18437 building_id: None,
18438 }];
18439
18440 state.inventory_tab = InventoryTab::OnPerson;
18441 let rows = state.inventory_selectable_rows();
18442 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
18443 assert_eq!(
18444 sections,
18445 vec![
18446 InventorySection::Person, InventorySection::Person, ]
18449 );
18450 assert_eq!(rows[0].stack.template_id, "iron_ore");
18451 assert_eq!(rows[0].depth, 0);
18452 assert!(!rows[0].is_equip_shell);
18453 assert_eq!(rows[1].stack.template_id, "lumber");
18454
18455 let lines = state.inventory_browser_lines();
18456 assert!(lines.iter().any(|l| matches!(
18457 l,
18458 InventoryBrowserLine::Section(s) if s.contains("carried bags")
18459 )));
18460 assert!(lines.iter().any(|l| matches!(
18461 l,
18462 InventoryBrowserLine::Item { text, .. } if text.contains("iron_ore")
18463 )));
18464 assert!(!lines.iter().any(|l| matches!(
18465 l,
18466 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
18467 )));
18468 assert!(!lines.iter().any(|l| matches!(
18469 l,
18470 InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
18471 )));
18472
18473 state.inventory_tab = InventoryTab::Nearby;
18474 let nearby_rows = state.inventory_selectable_rows();
18475 assert_eq!(nearby_rows.len(), 2);
18476 assert!(nearby_rows[0].is_chest_shell);
18477 assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
18478 let nearby_lines = state.inventory_browser_lines();
18479 assert!(nearby_lines.iter().any(|l| matches!(
18480 l,
18481 InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
18482 )));
18483 }
18484
18485 #[test]
18486 fn give_worker_notice_does_not_put_item_back_in_bag() {
18487 let mut state = sample_state();
18488 let id = uuid::Uuid::from_u128(42);
18489 let mut saw = flatland_protocol::ItemStack::simple("handsaw", 1);
18490 saw.item_instance_id = Some(id);
18491 saw.display_name = Some("Handsaw".into());
18492 state.sync_inventory_from_stacks(&[saw]);
18493 assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 1);
18494
18495 state.remove_carried_instance(id, None);
18496 assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 0);
18497 assert!(state.inventory_stacks.is_empty());
18498
18499 state.apply_interaction_notice(&flatland_protocol::InteractionNotice {
18500 target_id: "worker-1".into(),
18501 message: "Gave 1x Handsaw to Laborer".into(),
18502 coins_delta: 0,
18503 inventory_delta: vec![flatland_protocol::ItemStack::simple("handsaw", 1)],
18504 });
18505 assert_eq!(
18506 state.inventory.get("handsaw").copied().unwrap_or(0),
18507 0,
18508 "Gave notice must not restore the handed stack"
18509 );
18510 }
18511
18512 #[test]
18513 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
18514 let mut state = sample_state();
18515 let back_id = uuid::Uuid::from_u128(5);
18516 state.worn.insert(
18517 BodySlot::Back,
18518 flatland_protocol::ItemStack {
18519 template_id: "travel_backpack".into(),
18520 quantity: 1,
18521 item_instance_id: Some(back_id),
18522 props: Default::default(),
18523 status_bindings: Vec::new(),
18524 contents: Vec::new(),
18525 display_name: None,
18526 category: Some("container".into()),
18527 base_mass: None,
18528 base_volume: None,
18529 capacity_volume: Some(80.0),
18530 stackable: None,
18531 world_placeable: None,
18532 worker_lodging_capacity: None,
18533 equip_slot: None,
18534 armor_physical: None,
18535 resists: vec![],
18536 hand_slots: None,
18537 listable: None,
18538 ..Default::default()
18539 },
18540 );
18541 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18542 id: "chest-1".into(),
18543 template_id: "wooden_chest_small".into(),
18544 display_name: "Wooden Chest".into(),
18545 x: 129.0,
18546 y: 128.0,
18547 z: 0.0,
18548 locked: false,
18549 accessible: true,
18550 owner_character_id: None,
18551 contents: Vec::new(),
18552 lock_id: None,
18553 capacity_volume: None,
18554 item_instance_id: Some(uuid::Uuid::from_u128(6)),
18555 tile_id: None,
18556 worker_lodging_capacity: None,
18557 blocking: false,
18558 blocking_radius_m: 0.0,
18559 building_id: None,
18560 }];
18561
18562 let opts = state.move_destinations_for(
18565 &flatland_protocol::InventoryLocation::Root,
18566 None,
18567 None,
18568 "lumber",
18569 );
18570 assert!(!opts.iter().any(|o| matches!(
18571 &o.kind,
18572 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18573 )));
18574 assert!(opts.iter().any(|o| matches!(
18575 &o.kind,
18576 MoveOptionKind::Move { location, parent_instance_id, .. }
18577 if *location == flatland_protocol::InventoryLocation::Worn {
18578 slot: BodySlot::Back,
18579 } && *parent_instance_id == Some(back_id)
18580 )));
18581 assert!(opts.iter().any(|o| matches!(
18582 &o.kind,
18583 MoveOptionKind::Move { location, .. }
18584 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
18585 )));
18586 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18587 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
18588
18589 let from_backpack = flatland_protocol::InventoryLocation::Worn {
18593 slot: BodySlot::Back,
18594 };
18595 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
18596 assert!(!opts.iter().any(|o| matches!(
18597 &o.kind,
18598 MoveOptionKind::Move { location, parent_instance_id, .. }
18599 if *location == from_backpack && *parent_instance_id == Some(back_id)
18600 )));
18601 assert!(opts.iter().any(|o| matches!(
18602 &o.kind,
18603 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18604 )));
18605 }
18606
18607 #[test]
18608 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
18609 let mut state = sample_state();
18610 state.worn.insert(
18613 BodySlot::Waist,
18614 flatland_protocol::ItemStack {
18615 template_id: "simple_belt".into(),
18616 quantity: 1,
18617 item_instance_id: Some(uuid::Uuid::from_u128(10)),
18618 props: Default::default(),
18619 status_bindings: Vec::new(),
18620 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
18621 display_name: None,
18622 category: Some("container".into()),
18623 base_mass: None,
18624 base_volume: None,
18625 capacity_volume: None,
18626 stackable: None,
18627 world_placeable: None,
18628 worker_lodging_capacity: None,
18629 equip_slot: None,
18630 armor_physical: None,
18631 resists: vec![],
18632 hand_slots: None,
18633 listable: None,
18634 ..Default::default()
18635 },
18636 );
18637 state.worn.insert(
18638 BodySlot::Head,
18639 flatland_protocol::ItemStack {
18640 template_id: "cloth_cap".into(),
18641 quantity: 1,
18642 item_instance_id: Some(uuid::Uuid::from_u128(11)),
18643 props: Default::default(),
18644 status_bindings: Vec::new(),
18645 contents: Vec::new(),
18646 display_name: None,
18647 category: Some("armor".into()),
18648 base_mass: None,
18649 base_volume: None,
18650 capacity_volume: None,
18651 stackable: None,
18652 world_placeable: None,
18653 worker_lodging_capacity: None,
18654 equip_slot: None,
18655 armor_physical: None,
18656 resists: vec![],
18657 hand_slots: None,
18658 listable: None,
18659 ..Default::default()
18660 },
18661 );
18662 state.worn.insert(
18663 BodySlot::Back,
18664 flatland_protocol::ItemStack {
18665 template_id: "travel_backpack".into(),
18666 quantity: 1,
18667 item_instance_id: Some(uuid::Uuid::from_u128(12)),
18668 props: Default::default(),
18669 status_bindings: Vec::new(),
18670 contents: Vec::new(),
18671 display_name: None,
18672 category: Some("container".into()),
18673 base_mass: None,
18674 base_volume: None,
18675 capacity_volume: None,
18676 stackable: None,
18677 world_placeable: None,
18678 worker_lodging_capacity: None,
18679 equip_slot: None,
18680 armor_physical: None,
18681 resists: vec![],
18682 hand_slots: None,
18683 listable: None,
18684 ..Default::default()
18685 },
18686 );
18687
18688 let rows = state.worn_rows();
18689 assert_eq!(rows.len(), 4);
18691 assert_eq!(rows[0].stack.template_id, "cloth_cap");
18692 assert!(rows[0].is_equip_shell);
18693 assert_eq!(rows[1].stack.template_id, "travel_backpack");
18694 assert!(rows[1].is_equip_shell);
18695 assert_eq!(rows[2].stack.template_id, "simple_belt");
18696 assert!(rows[2].is_equip_shell);
18697 assert_eq!(rows[3].stack.template_id, "leather_pouch");
18698 assert_eq!(rows[3].depth, 1);
18699 assert!(!rows[3].is_equip_shell);
18700 }
18701
18702 #[test]
18703 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
18704 let mut state = sample_state();
18705 state.worn.insert(
18706 BodySlot::Waist,
18707 flatland_protocol::ItemStack {
18708 template_id: "simple_belt".into(),
18709 quantity: 1,
18710 item_instance_id: Some(uuid::Uuid::from_u128(20)),
18711 props: Default::default(),
18712 status_bindings: Vec::new(),
18713 contents: Vec::new(),
18714 display_name: Some("Simple Belt".into()),
18715 category: Some("container".into()),
18716 base_mass: None,
18717 base_volume: None,
18718 capacity_volume: None,
18719 stackable: None,
18720 world_placeable: None,
18721 worker_lodging_capacity: None,
18722 equip_slot: None,
18723 armor_physical: None,
18724 resists: vec![],
18725 hand_slots: None,
18726 listable: None,
18727 ..Default::default()
18728 },
18729 );
18730 state.worn.insert(
18731 BodySlot::Head,
18732 flatland_protocol::ItemStack {
18733 template_id: "cloth_cap".into(),
18734 quantity: 1,
18735 item_instance_id: Some(uuid::Uuid::from_u128(21)),
18736 props: Default::default(),
18737 status_bindings: Vec::new(),
18738 contents: Vec::new(),
18739 display_name: Some("Cloth Cap".into()),
18740 category: Some("armor".into()),
18741 base_mass: None,
18742 base_volume: None,
18743 capacity_volume: None,
18744 stackable: None,
18745 world_placeable: None,
18746 worker_lodging_capacity: None,
18747 equip_slot: None,
18748 armor_physical: None,
18749 resists: vec![],
18750 hand_slots: None,
18751 listable: None,
18752 ..Default::default()
18753 },
18754 );
18755
18756 let opts = state.move_destinations_for(
18757 &flatland_protocol::InventoryLocation::Root,
18758 None,
18759 None,
18760 "leather_pouch",
18761 );
18762 assert!(
18763 opts.iter().any(|o| matches!(
18764 &o.kind,
18765 MoveOptionKind::Move { location, .. }
18766 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18767 )),
18768 "belt loop must be offered when moving a pouch"
18769 );
18770 assert!(
18771 !opts.iter().any(|o| matches!(
18772 &o.kind,
18773 MoveOptionKind::Move { location, .. }
18774 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
18775 )),
18776 "armor slots can't hold other items and must not appear as move destinations"
18777 );
18778 let belt_opt = opts
18779 .iter()
18780 .find(|o| matches!(
18781 &o.kind,
18782 MoveOptionKind::Move { location, .. }
18783 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18784 ))
18785 .unwrap();
18786 assert!(belt_opt.label.contains("belt loop"));
18787
18788 let opts = state.move_destinations_for(
18789 &flatland_protocol::InventoryLocation::Root,
18790 None,
18791 None,
18792 "lumber",
18793 );
18794 assert!(
18795 !opts.iter().any(|o| o.label.contains("belt loop")),
18796 "loose materials must not target the belt shell — only nested pouches"
18797 );
18798 }
18799
18800 #[test]
18801 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
18802 let mut state = sample_state();
18803 let belt_id = uuid::Uuid::from_u128(30);
18804 let pouch_id = uuid::Uuid::from_u128(31);
18805 state.worn.insert(
18806 BodySlot::Waist,
18807 flatland_protocol::ItemStack {
18808 template_id: "simple_belt".into(),
18809 quantity: 1,
18810 item_instance_id: Some(belt_id),
18811 props: Default::default(),
18812 status_bindings: Vec::new(),
18813 world_placeable: None,
18814 worker_lodging_capacity: None,
18815 equip_slot: None,
18816 armor_physical: None,
18817 resists: vec![],
18818 hand_slots: None,
18819 contents: vec![flatland_protocol::ItemStack {
18820 template_id: "dimensional_pouch".into(),
18821 quantity: 1,
18822 item_instance_id: Some(pouch_id),
18823 props: Default::default(),
18824 status_bindings: Vec::new(),
18825 contents: Vec::new(),
18826 display_name: Some("Dimensional Pouch".into()),
18827 category: Some("container".into()),
18828 base_mass: None,
18829 base_volume: None,
18830 capacity_volume: Some(200.0),
18831 stackable: None,
18832 world_placeable: None,
18833 worker_lodging_capacity: None,
18834 equip_slot: None,
18835 armor_physical: None,
18836 resists: vec![],
18837 hand_slots: None,
18838 listable: None,
18839 ..Default::default()
18840 }],
18841 display_name: Some("Simple Belt".into()),
18842 category: Some("container".into()),
18843 base_mass: None,
18844 base_volume: None,
18845 capacity_volume: None,
18846 stackable: None,
18847 listable: None,
18848 ..Default::default()
18849 },
18850 );
18851
18852 let opts = state.move_destinations_for(
18853 &flatland_protocol::InventoryLocation::Root,
18854 None,
18855 None,
18856 "iron_ore",
18857 );
18858 assert!(
18859 opts.iter().any(|o| matches!(
18860 &o.kind,
18861 MoveOptionKind::Move {
18862 location,
18863 parent_instance_id,
18864 ..
18865 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18866 && *parent_instance_id == Some(pouch_id)
18867 )),
18868 "dimensional pouch clipped on belt must accept loose items"
18869 );
18870 assert!(
18871 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
18872 "destination label should name the pouch"
18873 );
18874 }
18875
18876 #[test]
18877 fn container_volume_label_on_placed_chest_shell() {
18878 let mut state = sample_state();
18879 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18880 id: "chest-1".into(),
18881 template_id: "wooden_chest_small".into(),
18882 display_name: "Camp Chest".into(),
18883 x: 129.0,
18884 y: 128.0,
18885 z: 0.0,
18886 locked: false,
18887 accessible: true,
18888 owner_character_id: None,
18889 contents: vec![flatland_protocol::ItemStack {
18890 template_id: "iron_ore".into(),
18891 quantity: 2,
18892 item_instance_id: None,
18893 props: Default::default(),
18894 status_bindings: Vec::new(),
18895 contents: Vec::new(),
18896 display_name: None,
18897 category: None,
18898 base_mass: None,
18899 base_volume: Some(2.0),
18900 capacity_volume: None,
18901 stackable: None,
18902 world_placeable: None,
18903 worker_lodging_capacity: None,
18904 equip_slot: None,
18905 armor_physical: None,
18906 resists: vec![],
18907 hand_slots: None,
18908 listable: None,
18909 ..Default::default()
18910 }],
18911 lock_id: None,
18912 capacity_volume: Some(60.0),
18913 item_instance_id: Some(uuid::Uuid::from_u128(4)),
18914 tile_id: None,
18915 worker_lodging_capacity: None,
18916 blocking: false,
18917 blocking_radius_m: 0.0,
18918 building_id: None,
18919 }];
18920 let nearby = state.nearby_containers();
18921 let label = state.container_volume_label(&nearby[0].rows[0]);
18922 assert!(
18923 label.contains("vol 4/60"),
18924 "expected used/cap in label, got {label}"
18925 );
18926 assert!(
18927 label.contains("56 free"),
18928 "expected free space, got {label}"
18929 );
18930 }
18931
18932 #[test]
18933 fn key_pair_chest_label_from_placed_lock_id() {
18934 let mut state = sample_state();
18935 let owner = uuid::Uuid::from_u128(77);
18936 state.character_id = Some(owner);
18937 let lock = uuid::Uuid::from_u128(99).to_string();
18938 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18939 id: "chest-1".into(),
18940 template_id: "wooden_chest_small".into(),
18941 display_name: "Barry's Loot #a3f2".into(),
18942 x: 129.0,
18943 y: 128.0,
18944 z: 0.0,
18945 locked: true,
18946 accessible: true,
18947 owner_character_id: Some(owner),
18948 contents: Vec::new(),
18949 lock_id: Some(lock.clone()),
18950 capacity_volume: None,
18951 item_instance_id: Some(uuid::Uuid::from_u128(4)),
18952 tile_id: None,
18953 worker_lodging_capacity: None,
18954 blocking: false,
18955 blocking_radius_m: 0.0,
18956 building_id: None,
18957 }];
18958 let key_id = uuid::Uuid::from_u128(5);
18959 let key = flatland_protocol::ItemStack {
18960 template_id: KEY_TEMPLATE.into(),
18961 quantity: 1,
18962 item_instance_id: Some(key_id),
18963 props: BTreeMap::from([
18964 (PROP_OPENS_LOCK_ID.into(), lock),
18965 (
18966 PROP_OPENS_CONTAINER_NAME.into(),
18967 "Barry's Loot #a3f2".into(),
18968 ),
18969 ]),
18970 status_bindings: Vec::new(),
18971 contents: Vec::new(),
18972 display_name: Some("Container Key".into()),
18973 category: Some("key".into()),
18974 base_mass: None,
18975 base_volume: None,
18976 capacity_volume: None,
18977 stackable: None,
18978 world_placeable: None,
18979 worker_lodging_capacity: None,
18980 equip_slot: None,
18981 armor_physical: None,
18982 resists: vec![],
18983 hand_slots: None,
18984 listable: None,
18985 ..Default::default()
18986 };
18987 state.inventory_stacks = vec![key.clone()];
18988 assert_eq!(
18989 state.key_pair_chest_label(&key).as_deref(),
18990 Some("Barry's Loot #a3f2")
18991 );
18992 assert!(state.key_drop_blocked(&key));
18993 }
18994
18995 #[test]
18996 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
18997 let mut state = sample_state();
18998 let lock = uuid::Uuid::from_u128(101).to_string();
18999 let key = flatland_protocol::ItemStack {
19000 template_id: KEY_TEMPLATE.into(),
19001 quantity: 1,
19002 item_instance_id: Some(uuid::Uuid::from_u128(7)),
19003 props: BTreeMap::from([
19004 (PROP_OPENS_LOCK_ID.into(), lock),
19005 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
19006 ]),
19007 status_bindings: Vec::new(),
19008 contents: Vec::new(),
19009 display_name: None,
19010 category: Some("key".into()),
19011 base_mass: None,
19012 base_volume: None,
19013 capacity_volume: None,
19014 stackable: None,
19015 world_placeable: None,
19016 worker_lodging_capacity: None,
19017 equip_slot: None,
19018 armor_physical: None,
19019 resists: vec![],
19020 hand_slots: None,
19021 listable: None,
19022 ..Default::default()
19023 };
19024 state.placed_containers.clear();
19025 assert_eq!(
19026 state.key_pair_chest_label(&key).as_deref(),
19027 Some("Camp Stash")
19028 );
19029 }
19030
19031 #[test]
19032 fn key_drop_allowed_when_paired_chest_unlocked() {
19033 let mut state = sample_state();
19034 let lock = uuid::Uuid::from_u128(100).to_string();
19035 let key_id = uuid::Uuid::from_u128(6);
19036 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19037 id: "chest-1".into(),
19038 template_id: "wooden_chest_small".into(),
19039 display_name: "Camp Chest".into(),
19040 x: 129.0,
19041 y: 128.0,
19042 z: 0.0,
19043 locked: false,
19044 accessible: true,
19045 owner_character_id: None,
19046 contents: Vec::new(),
19047 lock_id: Some(lock.clone()),
19048 capacity_volume: None,
19049 item_instance_id: None,
19050 tile_id: None,
19051 worker_lodging_capacity: None,
19052 blocking: false,
19053 blocking_radius_m: 0.0,
19054 building_id: None,
19055 }];
19056 let key = flatland_protocol::ItemStack {
19057 template_id: KEY_TEMPLATE.into(),
19058 quantity: 1,
19059 item_instance_id: Some(key_id),
19060 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
19061 status_bindings: Vec::new(),
19062 contents: Vec::new(),
19063 display_name: None,
19064 category: Some("key".into()),
19065 base_mass: None,
19066 base_volume: None,
19067 capacity_volume: None,
19068 stackable: None,
19069 world_placeable: None,
19070 worker_lodging_capacity: None,
19071 equip_slot: None,
19072 armor_physical: None,
19073 resists: vec![],
19074 hand_slots: None,
19075 listable: None,
19076 ..Default::default()
19077 };
19078 state.inventory_stacks = vec![key.clone()];
19079 assert!(!state.key_drop_blocked(&key));
19080 let opts = state.move_destinations_for(
19081 &flatland_protocol::InventoryLocation::Root,
19082 None,
19083 Some(key_id),
19084 KEY_TEMPLATE,
19085 );
19086 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
19087 }
19088
19089 #[test]
19090 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
19091 use flatland_protocol::{CombatHud, ProgressionCurve, ProgressionXp};
19092
19093 let mut state = sample_state();
19094 let curve = ProgressionCurve::default();
19095 let bootstrap =
19096 ProgressionXp::bootstrap_new(curve.baseline_display, curve.xp_base, curve.xp_growth);
19097 let mut fresh = bootstrap.clone();
19098 fresh.strength += 0.08;
19099 if let Some(player) = state.player.as_mut() {
19100 player.progression_xp = Some(bootstrap);
19101 }
19102
19103 let combat = CombatHud {
19104 progression_xp: Some(fresh.clone()),
19105 progression_baseline: curve.baseline_display,
19106 progression_xp_base: curve.xp_base,
19107 progression_xp_growth: curve.xp_growth,
19108 attributes: state.player.as_ref().and_then(|p| p.attributes),
19109 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
19110 ..CombatHud::default()
19111 };
19112 state.apply_combat_hud(&combat);
19113
19114 let xp = state
19115 .player
19116 .as_ref()
19117 .and_then(|p| p.progression_xp.as_ref())
19118 .expect("xp");
19119 assert!((xp.strength - fresh.strength).abs() < 0.001);
19120 assert!(state.progression_curve.is_some());
19121 }
19122
19123 #[test]
19124 fn combat_hud_syncs_known_abilities_and_hotbar() {
19125 use flatland_protocol::CombatHud;
19126
19127 let mut state = sample_state();
19128 let combat = CombatHud {
19129 known_abilities: vec!["unarmed".into(), "fireball".into()],
19130 hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
19131 max_abilities_per_rotation: 4,
19132 ability_id: "short_sword_slash".into(),
19133 ..CombatHud::default()
19134 };
19135 state.apply_combat_hud(&combat);
19136
19137 assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
19138 assert_eq!(state.hotbar_ability(1), Some("fireball"));
19139 assert_eq!(state.hotbar_ability(2), None);
19140 assert_eq!(state.hotbar_ability(3), Some("unarmed"));
19141 assert_eq!(state.max_abilities_per_rotation, 4);
19142 let choices = state.loadout_ability_choices();
19143 assert!(choices.iter().any(|a| a == "short_sword_slash"));
19144 assert!(choices.iter().any(|a| a == "fireball"));
19145 }
19146
19147 #[test]
19148 fn loadout_hotbar_choices_include_inventory_consumables() {
19149 let mut state = sample_state();
19150 state.known_abilities = vec!["unarmed".into()];
19151 state.weapon_ability_id = "unarmed".into();
19152 state.inventory_stacks = vec![flatland_protocol::ItemStack {
19153 template_id: "empty_bottle".into(),
19154 quantity: 1,
19155 item_instance_id: Some(uuid::Uuid::from_u128(9)),
19156 display_name: Some("Glass Bottle of Water".into()),
19157 category: Some("container".into()),
19158 props: [
19159 ("serving".into(), "1".into()),
19160 ("liquid_vessel".into(), "1".into()),
19161 ("serving_holds".into(), "liquid".into()),
19162 ]
19163 .into_iter()
19164 .collect(),
19165 ..Default::default()
19166 }];
19167 state.inventory.insert("empty_bottle".into(), 1);
19168 state.inventory_hints.insert(
19169 "empty_bottle".into(),
19170 InventoryHint {
19171 display_name: "Glass Bottle".into(),
19172 category: "container".into(),
19173 ..Default::default()
19174 },
19175 );
19176
19177 let choices = state.loadout_hotbar_choices();
19178 assert!(choices.iter().any(|c| c.binding == "unarmed"));
19179 let water = choices
19180 .iter()
19181 .find(|c| c.binding == "item:empty_bottle")
19182 .expect("serving bottle binding");
19183 assert_eq!(water.meta.as_deref(), Some("use"));
19184 assert!(water.label.contains("Glass Bottle of Water"));
19185 assert_eq!(state.hotbar_slot_label(1), None, "unbound until set");
19186 state.hotbar = vec![None, None, None, None, Some("item:empty_bottle".into())];
19187 assert_eq!(
19188 state.hotbar_slot_label(5).as_deref(),
19189 Some("Glass Bottle×1")
19190 );
19191 }
19192
19193 #[test]
19194 fn loadout_hotbar_choices_exclude_blueprint_scrolls() {
19195 let mut state = sample_state();
19196 state.known_abilities = vec!["unarmed".into()];
19197 state.weapon_ability_id = "unarmed".into();
19198 state.inventory_stacks = vec![
19199 flatland_protocol::ItemStack {
19200 template_id: "carrot".into(),
19201 quantity: 2,
19202 display_name: Some("Wild Carrot".into()),
19203 category: Some("consumable".into()),
19204 ..Default::default()
19205 },
19206 flatland_protocol::ItemStack {
19207 template_id: "blueprint_dimensional_pouch".into(),
19208 quantity: 1,
19209 display_name: Some("Blueprint — Dimensional Pouch".into()),
19210 category: Some("consumable".into()),
19211 props: [("teaches_blueprint".into(), "craft_dimensional_pouch".into())]
19212 .into_iter()
19213 .collect(),
19214 ..Default::default()
19215 },
19216 ];
19217 state.inventory.insert("carrot".into(), 2);
19218 state.inventory.insert("blueprint_dimensional_pouch".into(), 1);
19219 state.inventory_hints.insert(
19220 "carrot".into(),
19221 InventoryHint {
19222 display_name: "Wild Carrot".into(),
19223 category: "consumable".into(),
19224 ..Default::default()
19225 },
19226 );
19227 state.inventory_hints.insert(
19228 "blueprint_dimensional_pouch".into(),
19229 InventoryHint {
19230 display_name: "Blueprint — Dimensional Pouch".into(),
19231 category: "consumable".into(),
19232 ..Default::default()
19233 },
19234 );
19235
19236 let choices = state.loadout_hotbar_choices();
19237 assert!(choices.iter().any(|c| c.binding == "item:carrot"));
19238 assert!(
19239 choices
19240 .iter()
19241 .all(|c| c.binding != "item:blueprint_dimensional_pouch"),
19242 "recipe scrolls must not appear on the hotbar picker: {choices:?}"
19243 );
19244 }
19245
19246 #[test]
19247 fn storage_store_options_excludes_hand_equipped() {
19248 let mut state = sample_state();
19249 let sword_id = uuid::Uuid::from_u128(11);
19250 let ore_id = uuid::Uuid::from_u128(22);
19251 state.inventory_stacks = vec![
19252 flatland_protocol::ItemStack {
19253 template_id: "short_sword".into(),
19254 quantity: 1,
19255 item_instance_id: Some(sword_id),
19256 display_name: Some("Short Sword".into()),
19257 category: Some("weapon".into()),
19258 ..Default::default()
19259 },
19260 flatland_protocol::ItemStack {
19261 template_id: "iron_ore".into(),
19262 quantity: 5,
19263 item_instance_id: Some(ore_id),
19264 display_name: Some("Iron Ore".into()),
19265 category: Some("resource".into()),
19266 ..Default::default()
19267 },
19268 ];
19269 state.mainhand_template_id = Some("short_sword".into());
19270 state.mainhand_instance_id = Some(sword_id);
19271
19272 let opts = state.storage_store_options();
19273 assert_eq!(opts.len(), 1);
19274 assert_eq!(opts[0].item_instance_id, ore_id);
19275 assert!(state.hand_equipped_instance_ids().contains(&sword_id));
19276 }
19277
19278 #[test]
19279 fn loose_consumable_move_picker_offers_use_and_storage() {
19280 let mut state = sample_state();
19281 let inst = uuid::Uuid::from_u128(77);
19282 state.inventory_stacks = vec![flatland_protocol::ItemStack {
19283 template_id: "carrot".into(),
19284 quantity: 2,
19285 item_instance_id: Some(inst),
19286 props: Default::default(),
19287 status_bindings: Vec::new(),
19288 contents: Vec::new(),
19289 display_name: Some("Wild Carrot".into()),
19290 category: Some("consumable".into()),
19291 base_mass: None,
19292 base_volume: None,
19293 capacity_volume: None,
19294 stackable: Some(true),
19295 world_placeable: None,
19296 worker_lodging_capacity: None,
19297 equip_slot: None,
19298 armor_physical: None,
19299 resists: vec![],
19300 hand_slots: None,
19301 listable: None,
19302 ..Default::default()
19303 }];
19304 state.inventory_hints.insert(
19305 "carrot".into(),
19306 InventoryHint {
19307 display_name: "Wild Carrot".into(),
19308 category: "consumable".into(),
19309 base_mass: Some(0.15),
19310 base_volume: Some(0.3),
19311 capacity_volume: None,
19312 stackable: true,
19313 listable: true,
19314 base_value_copper: None,
19315 },
19316 );
19317 state.show_inventory_menu = true;
19318 state.inventory_menu_index = 0;
19319
19320 let row = state.inventory_selected_row().expect("carrot row");
19321 let mut options = state.move_destinations_for(
19322 &row.from,
19323 row.from_parent_instance_id,
19324 row.stack.item_instance_id,
19325 &row.stack.template_id,
19326 );
19327 if row.from == flatland_protocol::InventoryLocation::Root
19328 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
19329 {
19330 options.insert(
19331 0,
19332 MoveOption {
19333 label: "Use (eat / drink)".into(),
19334 kind: MoveOptionKind::Use,
19335 },
19336 );
19337 }
19338
19339 assert_eq!(
19340 options.first().map(|o| &o.label),
19341 Some(&"Use (eat / drink)".into())
19342 );
19343 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
19344 assert!(options
19345 .iter()
19346 .any(|o| matches!(o.kind, MoveOptionKind::Drop)));
19347 }
19348
19349 #[test]
19350 fn inventory_category_group_order_is_stable() {
19351 assert_eq!(inventory_category_group("weapon").0, "Weapons");
19352 assert_eq!(inventory_category_group("armor").0, "Armor");
19353 assert_eq!(inventory_category_group("consumable").0, "Consumables");
19354 assert_eq!(inventory_category_group("liquid").0, "Consumables");
19355 assert_eq!(inventory_category_group("resource").0, "Resources");
19356 assert_eq!(inventory_category_group("container").0, "Containers");
19357 assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
19358 assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
19359 }
19360
19361 #[test]
19362 fn page_list_index_clamps_without_wrap() {
19363 assert_eq!(page_list_index(0, -1, 25), 0);
19364 assert_eq!(page_list_index(0, 1, 25), 10);
19365 assert_eq!(page_list_index(12, 1, 25), 22);
19366 assert_eq!(page_list_index(22, 1, 25), 24);
19367 assert_eq!(page_list_index(5, 1, 0), 0);
19368 assert_eq!(page_list_index(3, -1, 8), 0);
19369 }
19370
19371 #[test]
19372 fn inventory_filter_hides_non_matching_person_items() {
19373 let mut state = sample_state();
19374 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19375 sword.display_name = Some("Iron Sword".into());
19376 sword.category = Some("weapon".into());
19377 let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
19378 herb.display_name = Some("Wild Herb".into());
19379 herb.category = Some("consumable".into());
19380 state.inventory_stacks = vec![sword, herb];
19381 state.inventory_tab = InventoryTab::OnPerson;
19382 state.inventory_filter = "sword".into();
19383
19384 let rows = state.inventory_selectable_rows();
19385 assert_eq!(rows.len(), 1);
19386 assert_eq!(rows[0].stack.template_id, "iron_sword");
19387
19388 let lines = state.inventory_browser_lines();
19389 assert!(lines.iter().any(|l| matches!(
19390 l,
19391 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
19392 )));
19393 assert!(!lines.iter().any(|l| matches!(
19394 l,
19395 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
19396 )));
19397 }
19398
19399 #[test]
19400 fn list_filter_chars_reject_mac_arrow_glyphs() {
19401 assert!(is_list_filter_char('a'));
19402 assert!(is_list_filter_char(' '));
19403 assert!(is_list_filter_char('-'));
19404 assert!(!is_list_filter_char('\u{F700}'));
19405 assert!(!is_list_filter_char('\u{F701}'));
19406 assert!(!is_list_filter_char('\n'));
19407 }
19408
19409 #[test]
19410 fn ready_tab_keeps_in_progress_craft_after_inputs_spent() {
19411 let mut state = sample_state();
19412 state.craft_tab = CraftTab::Ready;
19413 state.blueprints = vec![BlueprintView {
19414 id: "plank".into(),
19415 label: "Plank".into(),
19416 craft_tier: 1,
19417 craft_ticks: 30,
19418 output: "wood_plank".into(),
19419 output_qty: 1,
19420 output_display_name: "Wood Plank".into(),
19421 station: None,
19422 category: None,
19423 inputs: vec![flatland_protocol::BlueprintIngredientView {
19424 template_id: "oak_log".into(),
19425 quantity: 1,
19426 consumed: true,
19427 display_name: "Oak Log".into(),
19428 }],
19429 required_tools: vec![],
19430 skill: None,
19431 failure_chance: 0.0,
19432 worker_train_copper: 0,
19433 }];
19434 state.inventory.clear();
19436 state.craft_channel_blueprint_id = Some("plank".into());
19437 state.timed_channel = Some(flatland_protocol::TimedChannelHud {
19438 label: "Crafting Plank".into(),
19439 channel: flatland_protocol::TimedChannelKind::Craft,
19440 ticks_remaining: 20,
19441 ticks_total: 30,
19442 ..Default::default()
19443 });
19444
19445 let idxs = state.craft_filtered_indices();
19446 assert_eq!(idxs, vec![0]);
19447 assert!(state.craft_blueprint_in_channel("plank"));
19448
19449 state.timed_channel = None;
19451 state.craft_channel_blueprint_id = None;
19452 assert!(state.craft_filtered_indices().is_empty());
19453 }
19454
19455 #[test]
19456 fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
19457 let mut state = sample_state();
19458 let id_a = uuid::Uuid::from_u128(0xa1);
19459 let id_b = uuid::Uuid::from_u128(0xb2);
19460 let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
19461 sword_a.display_name = Some("Iron Sword".into());
19462 sword_a.category = Some("weapon".into());
19463 sword_a.item_instance_id = Some(id_a);
19464 let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
19465 sword_b.display_name = Some("Iron Sword".into());
19466 sword_b.category = Some("weapon".into());
19467 sword_b.item_instance_id = Some(id_b);
19468 state.inventory_stacks = vec![sword_a, sword_b];
19469 state.inventory_tab = InventoryTab::OnPerson;
19470
19471 let lines = state.inventory_browser_lines();
19472 let items: Vec<_> = lines
19473 .iter()
19474 .filter_map(|l| match l {
19475 InventoryBrowserLine::Item {
19476 title,
19477 instance_tooltip,
19478 ..
19479 } => Some((title.clone(), instance_tooltip.clone())),
19480 _ => None,
19481 })
19482 .collect();
19483 assert_eq!(items.len(), 2);
19484 for (title, tip) in &items {
19485 assert!(
19486 !title.contains('#'),
19487 "title should not show instance suffix: {title}"
19488 );
19489 assert!(
19490 tip.is_some(),
19491 "two identical rows should expose instance on hover"
19492 );
19493 }
19494
19495 state.inventory_stacks.pop();
19496 let lines = state.inventory_browser_lines();
19497 let one = lines.iter().find_map(|l| match l {
19498 InventoryBrowserLine::Item {
19499 title,
19500 instance_tooltip,
19501 ..
19502 } => Some((title.clone(), instance_tooltip.clone())),
19503 _ => None,
19504 });
19505 let (title, tip) = one.expect("one sword row");
19506 assert!(!title.contains('#'));
19507 assert!(tip.is_none(), "single row should not need instance tooltip");
19508 }
19509
19510 #[test]
19511 fn inventory_person_rows_group_by_category() {
19512 let mut state = sample_state();
19513 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19514 sword.category = Some("weapon".into());
19515 sword.display_name = Some("Iron Sword".into());
19516 let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
19517 ore.category = Some("resource".into());
19518 ore.display_name = Some("Iron Ore".into());
19519 let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
19520 potion.category = Some("consumable".into());
19521 potion.display_name = Some("Health Potion".into());
19522 state.inventory_stacks = vec![ore, potion, sword];
19523 state.inventory_tab = InventoryTab::OnPerson;
19524
19525 let lines = state.inventory_browser_lines();
19526 let labels: Vec<&str> = lines
19527 .iter()
19528 .filter_map(|l| match l {
19529 InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
19530 _ => None,
19531 })
19532 .collect();
19533 assert!(
19534 labels.iter().any(|s| s.contains("Weapons")),
19535 "expected Weapons group: {labels:?}"
19536 );
19537 assert!(labels.iter().any(|s| s.contains("Consumables")));
19538 assert!(labels.iter().any(|s| s.contains("Resources")));
19539
19540 let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
19541 let consumable_pos = labels
19542 .iter()
19543 .position(|s| s.contains("Consumables"))
19544 .unwrap();
19545 let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
19546 assert!(weapon_pos < consumable_pos);
19547 assert!(consumable_pos < resource_pos);
19548 }
19549
19550 #[test]
19551 fn inventory_tab_cycle_resets_selection() {
19552 let mut state = sample_state();
19553 state.inventory_tab = InventoryTab::OnPerson;
19554 state.inventory_menu_index = 3;
19555 state.inventory_tab = state.inventory_tab.cycle(true);
19556 assert_eq!(state.inventory_tab, InventoryTab::Nearby);
19557 assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
19559 assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
19560 assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
19561 assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
19562 }
19563
19564 #[test]
19565 fn parse_bank_copper_amount_blank_and_zero_mean_all() {
19566 assert_eq!(parse_bank_copper_amount(""), Some(0));
19567 assert_eq!(parse_bank_copper_amount(" "), Some(0));
19568 assert_eq!(parse_bank_copper_amount("0"), Some(0));
19569 assert_eq!(parse_bank_copper_amount("250"), Some(250));
19570 assert_eq!(parse_bank_copper_amount("nope"), None);
19571 }
19572
19573 #[test]
19574 fn parse_storage_quantity_blank_and_zero_mean_all() {
19575 assert_eq!(parse_storage_quantity(""), Some(None));
19576 assert_eq!(parse_storage_quantity(" "), Some(None));
19577 assert_eq!(parse_storage_quantity("0"), Some(None));
19578 assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
19579 assert_eq!(parse_storage_quantity("nope"), None);
19580 }
19581
19582 #[test]
19583 fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
19584 assert!(worker_error_is_hud_noise("path stuck — repathing"));
19585 assert!(worker_error_is_hud_noise(
19586 "path stuck — nudged clear, repathing"
19587 ));
19588 assert!(worker_error_is_hud_noise(
19589 "returned to lodging after path failures"
19590 ));
19591 assert!(!worker_error_is_hud_noise(
19593 "path stuck — no lodging to reset to"
19594 ));
19595 assert!(!worker_error_is_hud_noise(
19596 "cannot reach Eli — idling"
19597 ));
19598 }
19599
19600 #[test]
19601 fn leaving_building_restores_outdoor_z_bands() {
19602 use flatland_protocol::{InteriorMapView, ZPlatformView};
19603
19604 let mut state = sample_state();
19605 state.z_platforms.clear();
19606 state.z_transitions.clear();
19607 state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
19608 state.interior_map = Some(InteriorMapView {
19609 building_id: "broker_hut".into(),
19610 blueprint_id: "broker_hut".into(),
19611 background_color: "#000".into(),
19612 default_floor_color: None,
19613 floor_height_m: 3.0,
19614 z_platforms: vec![ZPlatformView {
19615 id: "floor_0".into(),
19616 z: 0.0,
19617 x0: 0.0,
19618 y0: 0.0,
19619 x1: 8.0,
19620 y1: 8.0,
19621 }],
19622 z_transitions: vec![],
19623 rooms: vec![],
19624 room_doors: vec![],
19625 });
19626 state.sync_interior_map_context();
19627 assert_eq!(
19628 state.z_platforms.len(),
19629 1,
19630 "indoors installs interior platforms"
19631 );
19632 assert!(state.z_bands_outdoor_backup.is_some());
19633
19634 state.player.as_mut().unwrap().inside_building = None;
19635 state.sync_interior_map_context();
19636 assert!(
19637 state.z_platforms.is_empty(),
19638 "leaving must restore outdoor bands (empty), not leave interior platforms"
19639 );
19640 assert!(state.z_bands_outdoor_backup.is_none());
19641 assert!(state.interior_map.is_none());
19642 }
19643
19644 #[test]
19645 fn resource_node_route_label_prefers_friendly_label_with_suffix() {
19646 let node = ResourceNodeView {
19647 id: "crop-carrot-1_copy10".into(),
19648 label: "crop-carrot-1_copy10".into(),
19649 x: 0.0,
19650 y: 0.0,
19651 z: 0.0,
19652 item_template: "carrot".into(),
19653 state: ResourceNodeState::Available,
19654 blocking: false,
19655 blocking_radius_m: 0.5,
19656 harvest_off: false,
19657 tile_id: None,
19658 yaw: 0.0,
19659 pitch: 0.0,
19660 roll: 0.0,
19661 draw_scale: 1.0,
19662 sprite_mode: None,
19663 growth_progress: None,
19664 presentation_state: None,
19665 channel_start_tick: None,
19666 channel_end_tick: None,
19667 harvest_drop_templates: vec![],
19668 };
19669 let label = super::resource_node_route_label(&node);
19670 assert!(label.starts_with("Carrot ("), "got {label}");
19671 assert!(label.ends_with(')'), "got {label}");
19672
19673 let mut named = node;
19674 named.label = "Sweet Pad".into();
19675 named.id = "crop-carrot-a3f2b1c0".into();
19676 assert_eq!(super::resource_node_route_label(&named), "Sweet Pad (b1c0)");
19677 }
19678
19679 #[test]
19680 fn plot_public_label_uses_owner_zone_and_label() {
19681 let plot = flatland_protocol::PropertyPlotView {
19682 plot_id: uuid::Uuid::nil(),
19683 property_zone_id: "zone_a".into(),
19684 zone_label: Some("Starter Town East 1".into()),
19685 deed_instance_id: uuid::Uuid::nil(),
19686 x0: 0.0,
19687 y0: 0.0,
19688 x1: 4.0,
19689 y1: 4.0,
19690 upkeep_copper_per_day: 1,
19691 arrears_days: 0,
19692 is_mine: true,
19693 may_farm: true,
19694 purchase_basis_copper: 0,
19695 farm_public: false,
19696 public_tax_discount_bps: 0,
19697 farm_allow: vec![],
19698 owner_character_id: None,
19699 owner_label: Some("Madsin".into()),
19700 building_id: None,
19701 plot_code: "xyz1234a".into(),
19702 label: "Food Pad".into(),
19703 };
19704 assert_eq!(
19705 super::plot_public_label(&plot),
19706 "Madsin — Starter Town East 1 — Food Pad"
19707 );
19708 }
19709
19710 #[test]
19711 fn plot_public_label_uses_size_when_label_and_code_blank() {
19712 let plot = flatland_protocol::PropertyPlotView {
19713 plot_id: uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap(),
19714 property_zone_id: String::new(),
19715 zone_label: None,
19716 deed_instance_id: uuid::Uuid::nil(),
19717 x0: 10.0,
19718 y0: 20.0,
19719 x1: 18.0,
19720 y1: 28.0,
19721 upkeep_copper_per_day: 1,
19722 arrears_days: 0,
19723 is_mine: true,
19724 may_farm: true,
19725 purchase_basis_copper: 0,
19726 farm_public: false,
19727 public_tax_discount_bps: 0,
19728 farm_allow: vec![],
19729 owner_character_id: None,
19730 owner_label: None,
19731 building_id: None,
19732 plot_code: String::new(),
19733 label: String::new(),
19734 };
19735 assert_eq!(super::plot_public_label(&plot), "Homestead — Plot (8×8 m)");
19736 assert!(!super::plot_public_label(&plot).contains("19fe35f"));
19737 }
19738
19739 #[test]
19740 fn plot_stop_label_prefers_view_over_hex() {
19741 let plot_id = uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap();
19742 let plot = flatland_protocol::PropertyPlotView {
19743 plot_id,
19744 property_zone_id: "zone_a".into(),
19745 zone_label: Some("Starter Town East".into()),
19746 deed_instance_id: uuid::Uuid::nil(),
19747 x0: 0.0,
19748 y0: 0.0,
19749 x1: 4.0,
19750 y1: 4.0,
19751 upkeep_copper_per_day: 1,
19752 arrears_days: 0,
19753 is_mine: true,
19754 may_farm: true,
19755 purchase_basis_copper: 0,
19756 farm_public: false,
19757 public_tax_discount_bps: 0,
19758 farm_allow: vec![],
19759 owner_character_id: None,
19760 owner_label: Some("Madsin".into()),
19761 building_id: None,
19762 plot_code: "xyz1234a".into(),
19763 label: "Food Pad".into(),
19764 };
19765 assert_eq!(
19766 super::plot_stop_label(&[plot.clone()], plot_id),
19767 "Madsin — Starter Town East — Food Pad"
19768 );
19769 let missing = uuid::Uuid::parse_str("1a001590-0000-4000-8000-000000000002").unwrap();
19770 assert_eq!(super::plot_stop_label(&[plot], missing), "plot 1a001590");
19771 }
19772}