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 tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
6268 self.quest_log
6269 .iter()
6270 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
6271 .or_else(|| {
6272 self.quest_log
6273 .iter()
6274 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
6275 })
6276 }
6277
6278 pub fn nearby_lockable_door(&self) -> bool {
6280 let (px, py) = self.player_position();
6281 self.doors
6282 .iter()
6283 .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M)
6284 }
6285
6286 pub fn nearby_open_player_door(&self) -> bool {
6288 if self.effective_inside_building().is_some() {
6289 return false;
6290 }
6291 let (px, py) = self.player_position();
6292 self.doors.iter().any(|d| {
6293 if !d.open || d.locked {
6294 return false;
6295 }
6296 let player_house = self
6297 .buildings
6298 .iter()
6299 .find(|b| b.id == d.building_id)
6300 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6301 player_house && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M
6302 })
6303 }
6304
6305 pub fn nearby_player_exit_door(&self) -> bool {
6307 let Some(bid) = self.effective_inside_building() else {
6308 return false;
6309 };
6310 let (px, py) = self.player_position();
6311 self.doors.iter().any(|d| {
6312 if d.building_id != bid || d.portal.is_none() {
6313 return false;
6314 }
6315 let player_house = self
6316 .buildings
6317 .iter()
6318 .find(|b| b.id == d.building_id)
6319 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6320 player_house && (d.x - px).hypot(d.y - py) <= 1.5
6321 })
6322 }
6323
6324 pub fn nearest_interact_target(&self) -> Option<String> {
6326 let (px, py) = self.player_position();
6327 let inside = self.effective_inside_building();
6328
6329 #[derive(Clone, Copy, PartialEq, Eq)]
6330 enum Kind {
6331 Player,
6332 Npc,
6333 HiredWorker,
6334 QuestBoard,
6335 ExitDoor,
6336 EnterDoor,
6337 }
6338
6339 fn kind_class(kind: Kind) -> u8 {
6340 match kind {
6341 Kind::EnterDoor => 0,
6342 Kind::QuestBoard => 1,
6343 Kind::Player | Kind::Npc => 2,
6344 Kind::ExitDoor => 3,
6345 Kind::HiredWorker => 4,
6346 }
6347 }
6348
6349 fn kind_priority(kind: Kind) -> u8 {
6350 match kind {
6351 Kind::EnterDoor => 0,
6352 Kind::QuestBoard => 1,
6353 Kind::Player | Kind::Npc => 2,
6354 Kind::ExitDoor => 3,
6355 Kind::HiredWorker => 4,
6356 }
6357 }
6358
6359 let mut best: Option<(f32, Kind, String)> = None;
6360
6361 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
6362 if dist > max {
6363 return;
6364 }
6365 let replace = match best {
6366 None => true,
6367 Some((_, bk, _)) if kind_class(kind) < kind_class(bk) => true,
6368 Some((bd, bk, _))
6369 if kind_class(kind) == kind_class(bk) && dist < bd - 0.05 =>
6370 {
6371 true
6372 }
6373 Some((bd, bk, _))
6374 if kind_class(kind) == kind_class(bk) && (dist - bd).abs() <= 0.05 =>
6375 {
6376 kind_priority(kind) < kind_priority(bk)
6377 }
6378 _ => false,
6379 };
6380 if replace {
6381 best = Some((dist, kind, id));
6382 }
6383 };
6384
6385 for npc in &self.npcs {
6386 consider(
6387 distance(px, py, npc.x, npc.y),
6388 INTERACTION_RADIUS_M,
6389 Kind::Npc,
6390 npc.id.clone(),
6391 );
6392 }
6393
6394 for worker in &self.hired_workers {
6395 consider(
6396 distance(px, py, worker.x, worker.y),
6397 INTERACTION_RADIUS_M,
6398 Kind::HiredWorker,
6399 worker.instance_id.clone(),
6400 );
6401 }
6402
6403 for entity in &self.entities {
6404 if entity.id == self.entity_id
6405 || entity.vitals.is_none()
6406 || entity.label.trim().is_empty()
6407 {
6408 continue;
6409 }
6410 if self.hired_workers.iter().any(|w| w.entity_id == entity.id) {
6412 continue;
6413 }
6414 consider(
6415 distance(
6416 px,
6417 py,
6418 entity.transform.position.x,
6419 entity.transform.position.y,
6420 ),
6421 INTERACTION_RADIUS_M,
6422 Kind::Player,
6423 entity.id.to_string(),
6424 );
6425 }
6426
6427 for door in &self.doors {
6428 if let Some(ref bid) = inside {
6429 if door.building_id != *bid {
6430 continue;
6431 }
6432 let is_exit = door.portal.is_some();
6433 let max = if is_exit {
6434 INTERACTION_RADIUS_M
6435 } else {
6436 DOOR_INTERACTION_RADIUS_M
6437 };
6438 let kind = if is_exit {
6439 Kind::ExitDoor
6440 } else {
6441 Kind::EnterDoor
6442 };
6443 consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
6444 continue;
6445 }
6446 consider(
6447 distance(px, py, door.x, door.y),
6448 DOOR_INTERACTION_RADIUS_M,
6449 Kind::EnterDoor,
6450 door.id.clone(),
6451 );
6452 }
6453
6454 if inside.is_none() {
6455 for inter in &self.interactables {
6456 if inter.kind == "quest_board" {
6457 consider(
6458 distance(px, py, inter.x, inter.y),
6459 QUEST_BOARD_INTERACTION_RADIUS_M,
6460 Kind::QuestBoard,
6461 inter.id.clone(),
6462 );
6463 }
6464 }
6465 }
6466
6467 best.map(|(_, _, id)| id)
6468 }
6469
6470 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
6472 if self.effective_inside_building().is_some() {
6473 return None;
6474 }
6475 let (px, py) = self.player_position();
6476 self.interactables
6477 .iter()
6478 .filter(|i| i.kind == "quest_board")
6479 .map(|i| {
6480 let label = if i.label.is_empty() {
6481 "Quest board".to_string()
6482 } else {
6483 i.label.clone()
6484 };
6485 (label, distance(px, py, i.x, i.y))
6486 })
6487 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
6488 }
6489
6490 pub fn template_display_name(&self, template_id: &str) -> String {
6492 if let Some(name) = self
6493 .inventory_hints
6494 .get(template_id)
6495 .map(|h| h.display_name.clone())
6496 .filter(|n| !n.is_empty())
6497 {
6498 return name;
6499 }
6500 if let Some(entry) = self.item_catalog.get(template_id) {
6501 if !entry.display_name.trim().is_empty() {
6502 return entry.display_name.clone();
6503 }
6504 }
6505 humanize_template_id(template_id)
6506 }
6507
6508 pub fn catalog_entry(&self, template_id: &str) -> Option<&ItemCatalogEntryView> {
6509 self.item_catalog.get(template_id)
6510 }
6511
6512 pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
6514 if !display_name.is_empty() {
6515 display_name.to_string()
6516 } else {
6517 self.template_display_name(template_id)
6518 }
6519 }
6520
6521 pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
6522 self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
6523 }
6524
6525 pub fn blueprint_ingredient_label(
6526 &self,
6527 input: &flatland_protocol::BlueprintIngredientView,
6528 ) -> String {
6529 self.blueprint_item_label(&input.template_id, &input.display_name)
6530 }
6531
6532 pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
6533 self.blueprint_item_label(&tool.item, &tool.display_name)
6534 }
6535
6536 pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
6538 use crate::worker_route_editor::{
6539 node_candidates, node_candidates_stable, route_editor_lodging_anchor,
6540 };
6541 let lodging = self
6542 .worker_route_editor
6543 .as_ref()
6544 .and_then(|ed| ed.lodging_container_id.as_deref());
6545 match route_editor_lodging_anchor(lodging, &self.placed_containers) {
6546 Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
6547 None => node_candidates_stable(&self.resource_nodes),
6548 }
6549 }
6550
6551 pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
6552 if dist_m.is_nan() {
6553 return "—".into();
6554 }
6555 let from_bed = self
6556 .worker_route_editor
6557 .as_ref()
6558 .and_then(|ed| ed.lodging_container_id.as_deref())
6559 .and_then(|id| {
6560 self.placed_containers
6561 .iter()
6562 .find(|c| c.id == id)
6563 .map(|c| c.display_name.clone())
6564 });
6565 match from_bed {
6566 Some(bed) => format!("{dist_m:.0}m from {bed}"),
6567 None => format!("{dist_m:.0}m"),
6568 }
6569 }
6570
6571 pub fn placed_container_public_label(
6573 &self,
6574 c: &flatland_protocol::PlacedContainerView,
6575 ) -> String {
6576 let is_owner = match (self.character_id, c.owner_character_id) {
6577 (Some(me), Some(owner)) => me == owner,
6578 _ => false,
6579 };
6580 if is_owner {
6581 c.display_name.clone()
6582 } else {
6583 self.template_display_name(&c.template_id)
6584 }
6585 }
6586
6587 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
6589 let mut out = Vec::new();
6590 for stack in &self.inventory_stacks {
6591 if stack.template_id == KEY_TEMPLATE {
6592 out.push(KeychainEntry {
6593 stack: stack.clone(),
6594 stowed: false,
6595 });
6596 }
6597 }
6598 for stack in &self.keychain_stacks {
6599 if stack.template_id == KEY_TEMPLATE {
6600 out.push(KeychainEntry {
6601 stack: stack.clone(),
6602 stowed: true,
6603 });
6604 }
6605 }
6606 out
6607 }
6608
6609 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
6611 if stack.template_id != KEY_TEMPLATE {
6612 return None;
6613 }
6614 if let Some(name) = stack
6615 .props
6616 .get(PROP_OPENS_CONTAINER_NAME)
6617 .filter(|n| !n.is_empty())
6618 {
6619 return Some(name.clone());
6620 }
6621 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
6622 self.container_name_for_lock_id(opens)
6623 }
6624
6625 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
6627 if stack.template_id == KEY_TEMPLATE {
6628 self.template_display_name(KEY_TEMPLATE)
6629 } else {
6630 stack
6631 .display_name
6632 .clone()
6633 .unwrap_or_else(|| stack.template_id.clone())
6634 }
6635 }
6636
6637 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
6639 if stack.template_id != KEY_TEMPLATE {
6640 return String::new();
6641 }
6642 match self.key_pair_chest_label(stack) {
6643 Some(chest) if self.key_drop_blocked(stack) => {
6644 format!(" [key for {chest} — can't drop while locked]")
6645 }
6646 Some(chest) => format!(" [key for {chest}]"),
6647 None => " [key — unpaired]".into(),
6648 }
6649 }
6650
6651 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
6653 for c in &self.placed_containers {
6654 if c.lock_id.as_deref() == Some(lock) {
6655 return Some(c.display_name.clone());
6656 }
6657 }
6658 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
6659 self.worn
6660 .values()
6661 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
6662 })
6663 }
6664
6665 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
6667 if stack.template_id != KEY_TEMPLATE {
6668 return false;
6669 }
6670 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
6671 return false;
6672 };
6673 for c in &self.placed_containers {
6674 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
6675 return true;
6676 }
6677 }
6678 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
6679 return true;
6680 }
6681 self.worn
6682 .values()
6683 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
6684 }
6685
6686 pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
6688 stack.template_id == PROPERTY_DEED_TEMPLATE
6689 }
6690
6691 pub fn is_property_deed_template(template_id: &str) -> bool {
6692 template_id == PROPERTY_DEED_TEMPLATE
6693 }
6694
6695 pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
6696 stack
6697 .props
6698 .get("plot_id")
6699 .and_then(|s| uuid::Uuid::parse_str(s).ok())
6700 }
6701
6702 pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
6704 let (px, py) = self.player_position();
6705 let (cx, cy) = self.farm_plot_cell_under_player()?;
6706 let tx = cx as f32 + 0.5;
6707 let ty = cy as f32 + 0.5;
6708 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6709 return None;
6710 }
6711 let kind = self.terrain_at(tx, ty).or_else(|| self.terrain_at(px, py));
6712 if kind == Some(TerrainKindView::Tilled) {
6713 return None;
6714 }
6715 if matches!(
6716 kind,
6717 Some(TerrainKindView::ShallowWater)
6718 | Some(TerrainKindView::DeepWater)
6719 | Some(TerrainKindView::Rock)
6720 ) {
6721 return None;
6722 }
6723 Some((tx, ty))
6724 }
6725
6726 fn container_name_in_stacks(
6727 stacks: &[flatland_protocol::ItemStack],
6728 lock: &str,
6729 ) -> Option<String> {
6730 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
6731 for s in stacks {
6732 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
6733 return Some(GameState::stack_container_label(s));
6734 }
6735 if let Some(name) = walk(&s.contents, lock) {
6736 return Some(name);
6737 }
6738 }
6739 None
6740 }
6741 walk(stacks, lock)
6742 }
6743
6744 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
6745 stack
6746 .props
6747 .get(PROP_CUSTOM_NAME)
6748 .cloned()
6749 .or_else(|| stack.display_name.clone())
6750 .unwrap_or_else(|| stack.template_id.clone())
6751 }
6752
6753 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6754 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6755 for s in stacks {
6756 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
6757 return true;
6758 }
6759 if walk(&s.contents, lock) {
6760 return true;
6761 }
6762 }
6763 false
6764 }
6765 walk(stacks, lock)
6766 }
6767
6768 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
6769 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
6770 return Some(stack.clone());
6771 }
6772 for worn in self.worn.values() {
6773 if worn.item_instance_id == Some(instance_id) {
6774 return Some(worn.clone());
6775 }
6776 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
6777 return Some(stack.clone());
6778 }
6779 }
6780 None
6781 }
6782
6783 pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
6785 self.property_zones
6786 .iter()
6787 .enumerate()
6788 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6789 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6790 .map(|(_, z)| z)
6791 }
6792
6793 pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
6795 self.tax_zones
6796 .iter()
6797 .enumerate()
6798 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6799 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6800 .map(|(_, z)| z)
6801 }
6802
6803 pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
6805 let mut max_bps = 0u32;
6806 let mut y = y0 + 0.5;
6807 while y < y1 {
6808 let mut x = x0 + 0.5;
6809 while x < x1 {
6810 if let Some(tz) = self.tax_zone_at(x, y) {
6811 max_bps = max_bps.max(tz.rate_bps);
6812 }
6813 x += 1.0;
6814 }
6815 y += 1.0;
6816 }
6817 max_bps
6818 }
6819
6820 pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6822 let mode = self.claim_mode.as_ref()?;
6823 let w = mode.width_m.max(1) as f32;
6824 let h = mode.height_m.max(1) as f32;
6825 Some((
6826 mode.anchor_x,
6827 mode.anchor_y,
6828 mode.anchor_x + w,
6829 mode.anchor_y + h,
6830 ))
6831 }
6832
6833 pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6835 let mode = self.relocate_mode.as_ref()?;
6836 let x0 = mode.cursor_x.floor();
6837 let y0 = mode.cursor_y.floor();
6838 Some((x0, y0, x0 + 1.0, y0 + 1.0))
6839 }
6840
6841 pub fn claim_quote(&self) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
6844 let mode = self.claim_mode.as_ref()?;
6845 let zone = self.property_zones.iter().find(|z| z.id == mode.zone_id)?;
6846 let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
6847 let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
6848 let zone_area = zone_view_area_m2(zone).max(1.0);
6849 let area_frac = (area / zone_area).clamp(0.0, 1.0);
6850 let weight = self
6851 .property_plot_settings
6852 .as_ref()
6853 .map(|s| s.tax_premium_weight)
6854 .unwrap_or(0.5)
6855 .max(0.0);
6856 let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
6857 let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
6858 let purchase = ((zone.crown_price_copper as f64) * (area_frac as f64) * (premium as f64))
6859 .ceil()
6860 .max(0.0) as u64;
6861 let upkeep = if zone.upkeep_copper_per_day == 0 {
6862 0
6863 } else {
6864 ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
6865 .ceil()
6866 .max(1.0) as u64
6867 };
6868 let copper = crate::currency::copper_from_counts(&self.inventory);
6869 let can_afford = copper >= purchase;
6870 let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
6871 Some((purchase, upkeep, area, premium, can_afford, valid, reason))
6872 }
6873
6874 fn validate_claim_footprint(
6875 &self,
6876 zone: &flatland_protocol::PropertyZoneView,
6877 x0: f32,
6878 y0: f32,
6879 x1: f32,
6880 y1: f32,
6881 area: f32,
6882 ) -> (bool, String) {
6883 let min_area = self
6884 .property_plot_settings
6885 .as_ref()
6886 .map(|s| s.min_plot_area_m2)
6887 .unwrap_or(4.0);
6888 if area + f32::EPSILON < min_area {
6889 return (false, "plot too small".into());
6890 }
6891 if zone.max_area_m2.is_some_and(|m| area > m) {
6892 return (false, "plot exceeds max area".into());
6893 }
6894 if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
6895 return (false, "plot must lie inside the property zone".into());
6896 }
6897 if self
6898 .property_plots
6899 .iter()
6900 .any(|p| rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1))
6901 {
6902 return (false, "plot overlaps an existing claim".into());
6903 }
6904 (true, String::new())
6905 }
6906
6907 pub fn free_property_zone_under_player(&self) -> Option<&flatland_protocol::PropertyZoneView> {
6909 let (px, py) = self.player_position();
6910 let zone = self.property_zone_at(px, py)?;
6911 if self.property_plots.iter().any(|p| point_in_plot(px, py, p)) {
6912 return None;
6913 }
6914 Some(zone)
6915 }
6916
6917 pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
6919 let (px, py) = self.player_position();
6920 self.property_plots
6921 .iter()
6922 .find(|p| p.is_mine && point_in_plot(px, py, p))
6923 }
6924
6925 pub fn farmable_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 || p.may_farm) && point_in_plot(px, py, p))
6931 }
6932
6933 pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
6935 if self.farmable_plot_under_player().is_none() {
6936 return None;
6937 }
6938 let (px, py) = self.player_position();
6939 Some((px.floor() as i32, py.floor() as i32))
6940 }
6941
6942 fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
6943 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6944 self.resource_nodes.iter().any(|n| {
6945 let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
6946 ncx == cx && ncy == cy || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
6947 })
6948 }
6949
6950 fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
6951 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6952 let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
6953 || self
6954 .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
6955 .is_some_and(|z| z.kind == TerrainKindView::Tilled);
6956 if !tilled {
6957 return false;
6958 }
6959 !self.resource_node_occupies_farm_cell(cx, cy)
6960 }
6961
6962 pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
6964 let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
6965 return false;
6966 };
6967 self.free_tilled_plant_slot_at(cx, cy)
6968 }
6969
6970 pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
6972 let (px, py) = self.player_position();
6973 for dy in -2..=2 {
6974 for dx in -2..=2 {
6975 let cx = px.floor() as i32 + dx;
6976 let cy = py.floor() as i32 + dy;
6977 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6978 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6979 continue;
6980 }
6981 if self.free_tilled_plant_slot_at(cx, cy) {
6982 return true;
6983 }
6984 }
6985 }
6986 false
6987 }
6988
6989 fn stack_is_farm_seed(&self, stack: &flatland_protocol::ItemStack) -> bool {
6990 if stack.quantity == 0 {
6991 return false;
6992 }
6993 if stack.props.contains_key("seed_for") {
6994 return true;
6995 }
6996 if let Some(entry) = self.item_catalog.get(&stack.template_id) {
6997 if entry.is_farm_seed() {
6998 return true;
6999 }
7000 }
7001 stack.template_id.ends_with("_seed")
7002 }
7003
7004 pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
7006 let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
7007 fn walk(
7008 stacks: &[flatland_protocol::ItemStack],
7009 state: &GameState,
7010 counts: &mut std::collections::HashMap<String, u32>,
7011 ) {
7012 for s in stacks {
7013 if state.stack_is_farm_seed(s) {
7014 *counts.entry(s.template_id.clone()).or_default() += s.quantity;
7015 }
7016 walk(&s.contents, state, counts);
7017 }
7018 }
7019 walk(&self.inventory_stacks, self, &mut counts);
7020 for worn in self.worn.values() {
7021 walk(std::slice::from_ref(worn), self, &mut counts);
7022 }
7023 let mut out: Vec<_> = counts
7024 .into_iter()
7025 .map(|(template_id, quantity)| {
7026 let label = self.template_display_name(&template_id);
7027 (template_id, quantity, label)
7028 })
7029 .collect();
7030 out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
7031 out
7032 }
7033
7034 pub fn first_farm_seed_template(&self) -> Option<String> {
7036 self.farm_seed_entries()
7037 .into_iter()
7038 .next()
7039 .map(|(id, _, _)| id)
7040 }
7041
7042 pub fn clamp_plant_menu(&mut self) {
7043 let n = self.farm_seed_entries().len();
7044 if n == 0 {
7045 self.plant_menu_index = 0;
7046 self.plant_quantity = 1;
7047 return;
7048 }
7049 self.plant_menu_index = self.plant_menu_index.min(n - 1);
7050 let max_qty = self
7051 .farm_seed_entries()
7052 .get(self.plant_menu_index)
7053 .map(|(_, q, _)| *q)
7054 .unwrap_or(1)
7055 .max(1);
7056 self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
7057 }
7058
7059 pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
7060 let entries = self.farm_seed_entries();
7061 let (id, max, label) = entries.get(self.plant_menu_index)?;
7062 let qty = self.plant_quantity.min(*max).max(1);
7063 Some((id.clone(), qty, label.clone()))
7064 }
7065
7066 pub fn location_context_lines(&self) -> Vec<ContextLine> {
7068 let (px, py) = self.player_position();
7069 let inside = self.effective_inside_building();
7070 let mut lines = Vec::new();
7071
7072 if let Some(kind) = self.terrain_at(px, py) {
7073 lines.push(ContextLine {
7074 on_top: true,
7075 text: format!("Terrain: {}", terrain_kind_label(kind)),
7076 });
7077 }
7078
7079 if let Some(id) = inside.as_ref() {
7080 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
7081 lines.push(ContextLine {
7082 on_top: true,
7083 text: format!("Inside: {}", b.label),
7084 });
7085 }
7086 }
7087
7088 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
7089
7090 for node in &self.resource_nodes {
7091 if node.id.starts_with("preview:") {
7092 continue;
7093 }
7094 let dist = distance(px, py, node.x, node.y);
7095 if dist > NEARBY_SCAN_M {
7096 continue;
7097 }
7098 let on_top = dist <= ON_TOP_RADIUS_M;
7099 let prefix = if on_top { "On" } else { "Near" };
7100 let name = resource_node_near_display_label(&node.label);
7101 let action = resource_node_near_action_suffix(node);
7102 nearby.push((
7103 dist,
7104 ContextLine {
7105 on_top,
7106 text: format!("{prefix}: {name} ({dist:.1}m){action}"),
7107 },
7108 ));
7109 }
7110
7111 for drop in &self.ground_drops {
7112 let dist = distance(px, py, drop.x, drop.y);
7113 if dist > INTERACTION_RADIUS_M {
7114 continue;
7115 }
7116 let on_top = dist <= ON_TOP_RADIUS_M;
7117 let name = self.template_display_name(&drop.template_id);
7118 let prefix = if on_top { "On" } else { "Near" };
7119 let qty = if drop.quantity > 1 {
7120 format!(" ×{}", drop.quantity)
7121 } else {
7122 String::new()
7123 };
7124 nearby.push((
7125 dist,
7126 ContextLine {
7127 on_top,
7128 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
7129 },
7130 ));
7131 }
7132
7133 for c in &self.placed_containers {
7134 if !self.placed_container_in_current_space(c) {
7135 continue;
7136 }
7137 let dist = distance(px, py, c.x, c.y);
7138 if dist > CONTAINER_RANGE_M {
7139 continue;
7140 }
7141 let on_top = dist <= ON_TOP_RADIUS_M;
7142 let name = self.placed_container_public_label(c);
7143 let lock = if c.locked { " [locked]" } else { "" };
7144 let prefix = if on_top { "On" } else { "Near" };
7145 nearby.push((
7146 dist,
7147 ContextLine {
7148 on_top,
7149 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
7150 },
7151 ));
7152 }
7153
7154 for npc in &self.npcs {
7155 let dist = distance(px, py, npc.x, npc.y);
7156 if dist > NEARBY_SCAN_M {
7157 continue;
7158 }
7159 let on_top = dist <= ON_TOP_RADIUS_M;
7160 let prefix = if on_top { "On" } else { "Near" };
7161 nearby.push((
7162 dist,
7163 ContextLine {
7164 on_top,
7165 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
7166 },
7167 ));
7168 }
7169
7170 for door in &self.doors {
7171 let dist = distance(px, py, door.x, door.y);
7172 if dist > DOOR_INTERACTION_RADIUS_M {
7173 continue;
7174 }
7175 let building = self
7176 .buildings
7177 .iter()
7178 .find(|b| b.id == door.building_id)
7179 .map(|b| b.label.as_str())
7180 .unwrap_or(door.building_id.as_str());
7181 let player_house = self
7182 .buildings
7183 .iter()
7184 .find(|b| b.id == door.building_id)
7185 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
7186 let action = if inside.is_some() && door.portal.is_some() {
7187 if player_house {
7188 if door.locked {
7189 "locked — l unlock · Enter exit".to_string()
7190 } else if door.open {
7191 "close · Enter exit · l lock".to_string()
7192 } else {
7193 "open · Enter exit · l lock".to_string()
7194 }
7195 } else {
7196 "exit".to_string()
7197 }
7198 } else if player_house {
7199 if door.locked {
7200 "locked — l unlock".to_string()
7201 } else if door.open {
7202 "close · Enter go inside · l lock".to_string()
7203 } else {
7204 "open · l lock".to_string()
7205 }
7206 } else {
7207 "enter".to_string()
7208 };
7209 nearby.push((
7210 dist,
7211 ContextLine {
7212 on_top: dist <= ON_TOP_RADIUS_M,
7213 text: format!("{building} door ({dist:.1}m) — f {action}"),
7214 },
7215 ));
7216 }
7217
7218 if inside.is_none() {
7219 for inter in &self.interactables {
7220 if inter.kind != "quest_board" {
7221 continue;
7222 }
7223 let dist = distance(px, py, inter.x, inter.y);
7224 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
7225 continue;
7226 }
7227 let on_top = dist <= ON_TOP_RADIUS_M;
7228 let prefix = if on_top { "On" } else { "Near" };
7229 let label = if inter.label.is_empty() {
7230 "Quest board".to_string()
7231 } else {
7232 inter.label.clone()
7233 };
7234 nearby.push((
7235 dist,
7236 ContextLine {
7237 on_top,
7238 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
7239 },
7240 ));
7241 }
7242 }
7243
7244 if self.near_liquid_fill_source() {
7245 let on_water = matches!(
7246 self.terrain_at(px, py),
7247 Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
7248 );
7249 let well = self.buildings.iter().find(|b| {
7250 b.tags.iter().any(|t| t == "well") && {
7251 let hw = b.width_m * 0.5;
7252 let hd = b.depth_m * 0.5;
7253 let nx = px.clamp(b.x - hw, b.x + hw);
7254 let ny = py.clamp(b.y - hd, b.y + hd);
7255 let dx = px - nx;
7256 let dy = py - ny;
7257 dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
7258 }
7259 });
7260 if let Some(well) = well {
7261 let name = if well.label.trim().is_empty() {
7262 "Well"
7263 } else {
7264 well.label.as_str()
7265 };
7266 nearby.push((
7267 0.0,
7268 ContextLine {
7269 on_top: true,
7270 text: format!("{name} — Use a vessel from inventory to fill"),
7271 },
7272 ));
7273 } else if on_water {
7274 if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
7275 line.text
7276 .push_str(" — Use a vessel from inventory to fill");
7277 }
7278 } else {
7279 nearby.push((
7280 0.0,
7281 ContextLine {
7282 on_top: true,
7283 text: "Water nearby — Use a vessel from inventory to fill".into(),
7284 },
7285 ));
7286 }
7287 }
7288
7289 if self.claim_mode.is_some() {
7290 nearby.push((
7291 0.0,
7292 ContextLine {
7293 on_top: true,
7294 text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
7295 .into(),
7296 },
7297 ));
7298 } else if let Some(plot) = self.my_plot_under_player() {
7299 let name = plot_public_label(plot);
7300 let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
7301 format!("{name} — f again to sell to crown")
7302 } else {
7303 format!(
7304 "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
7305 )
7306 };
7307 nearby.push((
7308 0.0,
7309 ContextLine {
7310 on_top: true,
7311 text: prompt,
7312 },
7313 ));
7314 } else if let Some(plot) = self.farmable_plot_under_player() {
7315 let name = plot_public_label(plot);
7316 let disc = if plot.farm_public {
7317 plot.public_tax_discount_bps / 100
7318 } else {
7319 plot.farm_allow
7320 .iter()
7321 .find(|g| Some(g.character_id) == self.character_id)
7322 .map(|g| g.tax_discount_bps / 100)
7323 .unwrap_or(0)
7324 };
7325 nearby.push((
7326 0.0,
7327 ContextLine {
7328 on_top: true,
7329 text: format!(
7330 "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
7331 ),
7332 },
7333 ));
7334 } else if let Some(zone) = self.free_property_zone_under_player() {
7335 let label = zone
7336 .label
7337 .as_deref()
7338 .filter(|s| !s.trim().is_empty())
7339 .unwrap_or(zone.id.as_str());
7340 nearby.push((
7341 0.0,
7342 ContextLine {
7343 on_top: true,
7344 text: format!("Claimable land: {label} — k buy plot"),
7345 },
7346 ));
7347 }
7348
7349 for entity in &self.entities {
7350 if entity.id == self.entity_id {
7351 continue;
7352 }
7353 let dist = distance(
7354 px,
7355 py,
7356 entity.transform.position.x,
7357 entity.transform.position.y,
7358 );
7359 if dist > NEARBY_SCAN_M {
7360 continue;
7361 }
7362 let label = if entity.label.is_empty() {
7363 format!("entity {}", entity.id)
7364 } else {
7365 entity.label.clone()
7366 };
7367 nearby.push((
7368 dist,
7369 ContextLine {
7370 on_top: dist <= ON_TOP_RADIUS_M,
7371 text: format!("Near: {label} ({dist:.1}m)"),
7372 },
7373 ));
7374 }
7375
7376 nearby.sort_by(|a, b| {
7377 a.0.partial_cmp(&b.0)
7378 .unwrap_or(std::cmp::Ordering::Equal)
7379 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
7380 });
7381 lines.extend(nearby.into_iter().map(|(_, l)| l));
7382
7383 if lines.is_empty() {
7384 lines.push(ContextLine {
7385 on_top: false,
7386 text: "(nothing notable nearby)".into(),
7387 });
7388 }
7389
7390 lines
7391 }
7392}
7393
7394#[derive(Debug, Clone)]
7396pub struct ContextLine {
7397 pub on_top: bool,
7398 pub text: String,
7399}
7400
7401const ON_TOP_RADIUS_M: f32 = 0.65;
7402const NEARBY_SCAN_M: f32 = 5.0;
7403
7404pub fn resource_node_near_display_label(label: &str) -> String {
7406 label
7407 .strip_suffix(" (growing)")
7408 .unwrap_or(label)
7409 .to_string()
7410}
7411
7412fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
7413 let t = label.trim();
7414 if t.is_empty() || t == id {
7415 return true;
7416 }
7417 let lower = t.to_ascii_lowercase();
7418 if lower.contains("_copy") {
7419 return true;
7420 }
7421 false
7422}
7423
7424fn humanize_item_template_label(template: &str) -> String {
7425 let base = template.rsplit('/').next().unwrap_or(template).trim();
7426 if base.is_empty() {
7427 return "Resource".into();
7428 }
7429 let stripped = base
7430 .strip_prefix("crop-")
7431 .or_else(|| base.strip_prefix("crop_"))
7432 .unwrap_or(base);
7433 stripped
7434 .split(|c: char| c == '-' || c == '_')
7435 .filter(|p| !p.is_empty())
7436 .map(|p| {
7437 let mut chars = p.chars();
7438 match chars.next() {
7439 Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
7440 None => String::new(),
7441 }
7442 })
7443 .collect::<Vec<_>>()
7444 .join(" ")
7445}
7446
7447pub fn resource_node_id_suffix(id: &str) -> String {
7449 let chars: Vec<char> = id
7450 .chars()
7451 .rev()
7452 .filter(|c| c.is_ascii_alphanumeric())
7453 .take(4)
7454 .collect();
7455 chars.into_iter().rev().collect()
7456}
7457
7458pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
7460 resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
7461}
7462
7463pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
7464 let cleaned = resource_node_near_display_label(label);
7465 let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
7466 cleaned
7467 } else if !item_template.trim().is_empty() {
7468 humanize_item_template_label(item_template)
7469 } else {
7470 id.to_string()
7471 };
7472 let suffix = resource_node_id_suffix(id);
7473 if suffix.is_empty() {
7474 friendly
7475 } else {
7476 format!("{friendly} ({suffix})")
7477 }
7478}
7479
7480pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
7482 use flatland_protocol::ResourceNodeState;
7483 if node.harvest_off {
7484 return " (decorative)".to_string();
7485 }
7486 if let Some(p) = node.growth_progress {
7487 if p < 1.0 - f32::EPSILON {
7488 let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
7489 return format!(" (growing, {pct}%)");
7490 }
7491 return " — f harvest".to_string();
7492 }
7493 match node.state {
7494 ResourceNodeState::Available => " — f harvest".to_string(),
7495 ResourceNodeState::Harvesting => " (being harvested)".to_string(),
7496 ResourceNodeState::Cooldown => " (depleted)".to_string(),
7497 }
7498}
7499
7500fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
7501 use flatland_protocol::TerrainKindView;
7502 match kind {
7503 TerrainKindView::Grass => "Grass",
7504 TerrainKindView::Dirt => "Dirt",
7505 TerrainKindView::Tilled => "Tilled",
7506 TerrainKindView::Desert => "Desert",
7507 TerrainKindView::Hill => "Hills",
7508 TerrainKindView::Bog => "Bog",
7509 TerrainKindView::Beach => "Beach",
7510 TerrainKindView::ShallowWater => "Shallow water",
7511 TerrainKindView::DeepWater => "Deep water",
7512 TerrainKindView::Trail => "Trail",
7513 TerrainKindView::Road => "Road",
7514 TerrainKindView::Rock => "Rock",
7515 }
7516}
7517
7518fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
7519 crate::world_zones::zone_rects_contain(rects, x, y)
7520}
7521
7522fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
7523 zone.rects
7524 .iter()
7525 .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
7526 .sum()
7527}
7528
7529fn claim_rect_fully_inside_zone(
7530 zone: &flatland_protocol::PropertyZoneView,
7531 x0: f32,
7532 y0: f32,
7533 x1: f32,
7534 y1: f32,
7535) -> bool {
7536 let mut y = y0 + 0.5;
7537 while y < y1 {
7538 let mut x = x0 + 0.5;
7539 while x < x1 {
7540 if !zone_rects_contain(&zone.rects, x, y) {
7541 return false;
7542 }
7543 x += 1.0;
7544 }
7545 y += 1.0;
7546 }
7547 true
7548}
7549
7550fn rects_overlap_half_open(
7551 ax0: f32,
7552 ay0: f32,
7553 ax1: f32,
7554 ay1: f32,
7555 bx0: f32,
7556 by0: f32,
7557 bx1: f32,
7558 by1: f32,
7559) -> bool {
7560 ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
7561}
7562
7563fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
7564 x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
7565}
7566
7567fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
7568 plot_public_label(p)
7569}
7570
7571fn plot_size_fallback_label(p: &flatland_protocol::PropertyPlotView) -> String {
7572 let w = (p.x1 - p.x0).abs();
7573 let d = (p.y1 - p.y0).abs();
7574 format!("Plot ({w:.0}×{d:.0} m)")
7575}
7576
7577pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
7579 let zone = p
7580 .zone_label
7581 .as_deref()
7582 .filter(|s| !s.trim().is_empty())
7583 .unwrap_or_else(|| {
7584 if p.property_zone_id.is_empty() {
7585 "Homestead"
7586 } else {
7587 p.property_zone_id.as_str()
7588 }
7589 });
7590 let label = if !p.label.trim().is_empty() {
7591 p.label.clone()
7592 } else if !p.plot_code.trim().is_empty() {
7593 p.plot_code.clone()
7594 } else {
7595 plot_size_fallback_label(p)
7596 };
7597 match p
7598 .owner_label
7599 .as_deref()
7600 .map(str::trim)
7601 .filter(|s| !s.is_empty())
7602 {
7603 Some(owner) => format!("{owner} — {zone} — {label}"),
7604 None => format!("{zone} — {label}"),
7605 }
7606}
7607
7608pub fn plot_stop_label(
7613 plots: &[flatland_protocol::PropertyPlotView],
7614 plot_id: uuid::Uuid,
7615) -> String {
7616 plots
7617 .iter()
7618 .find(|p| p.plot_id == plot_id)
7619 .map(plot_public_label)
7620 .unwrap_or_else(|| {
7621 let s = plot_id.to_string();
7622 format!("plot {}", s.get(..8).unwrap_or(s.as_str()))
7623 })
7624}
7625
7626fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
7628 let a = x0.min(x1).floor();
7629 let b = y0.min(y1).floor();
7630 let mut c = x0.max(x1).ceil();
7631 let mut d = y0.max(y1).ceil();
7632 if (c - a) < 1.0 {
7633 c = a + 1.0;
7634 }
7635 if (d - b) < 1.0 {
7636 d = b + 1.0;
7637 }
7638 (a, b, c, d)
7639}
7640
7641fn humanize_template_id(template_id: &str) -> String {
7642 if looks_like_template_uuid(template_id) {
7644 return "Unknown item".into();
7645 }
7646 template_id
7647 .split('_')
7648 .map(|word| {
7649 let mut chars = word.chars();
7650 match chars.next() {
7651 None => String::new(),
7652 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
7653 }
7654 })
7655 .collect::<Vec<_>>()
7656 .join(" ")
7657}
7658
7659fn looks_like_template_uuid(template_id: &str) -> bool {
7660 let bytes = template_id.as_bytes();
7661 if bytes.len() != 36 {
7662 return false;
7663 }
7664 let is_hex = |b: u8| b.is_ascii_hexdigit();
7665 let groups = [8usize, 4, 4, 4, 12];
7666 let mut i = 0;
7667 for (gi, &len) in groups.iter().enumerate() {
7668 if gi > 0 {
7669 if bytes.get(i) != Some(&b'-') {
7670 return false;
7671 }
7672 i += 1;
7673 }
7674 for _ in 0..len {
7675 if !bytes.get(i).copied().is_some_and(is_hex) {
7676 return false;
7677 }
7678 i += 1;
7679 }
7680 }
7681 true
7682}
7683
7684const HARVEST_RANGE_M: f32 = 1.5;
7686
7687pub struct GameClient<S: PlayConnection> {
7688 session: S,
7689 seq: Seq,
7690 pub state: GameState,
7691 last_move_forward: f32,
7692 last_move_strafe: f32,
7693}
7694
7695impl<S: PlayConnection> GameClient<S> {
7696 pub fn new(session: S) -> Self {
7697 let session_id = session.session_id();
7698 let entity_id = session.entity_id();
7699 let mut client = Self {
7700 session,
7701 seq: 0,
7702 last_move_forward: 0.0,
7703 last_move_strafe: 0.0,
7704 state: GameState {
7705 session_id,
7706 entity_id,
7707 character_id: None,
7708 tick: 0,
7709 chunk_rev: 0,
7710 content_rev: 0,
7711 publish_rev: 0,
7712 entities: Vec::new(),
7713 player: None,
7714 resource_nodes: Vec::new(),
7715 ground_drops: Vec::new(),
7716 placed_containers: Vec::new(),
7717 buildings: Vec::new(),
7718 doors: Vec::new(),
7719 interior_map: None,
7720 npcs: Vec::new(),
7721 blueprints: Vec::new(),
7722 building_materials: Vec::new(),
7723 world_x0: 0.0,
7724 world_y0: 0.0,
7725 world_width_m: 0.0,
7726 world_height_m: 0.0,
7727 terrain_zones: Vec::new(),
7728 z_platforms: Vec::new(),
7729 z_transitions: Vec::new(),
7730 z_bands_outdoor_backup: None,
7731 world_clock: flatland_protocol::WorldClock::default(),
7732 inventory: std::collections::HashMap::new(),
7733 inventory_hints: std::collections::HashMap::new(),
7734 item_catalog: std::collections::HashMap::new(),
7735 logs: VecDeque::new(),
7736 intents_sent: 0,
7737 ticks_received: 0,
7738 connected: false,
7739 disconnect_reason: None,
7740 show_stats: false,
7741 hud_log_hidden: false,
7742 show_equip_menu: false,
7743 equip_menu_index: 0,
7744 show_craft_menu: false,
7745 show_plot_build_menu: false,
7746 plot_build_focus_wall: true,
7747 plot_build_wall_index: 0,
7748 plot_build_roof_index: 0,
7749 craft_menu_index: 0,
7750 craft_batch_quantity: 1,
7751 craft_tab: CraftTab::Ready,
7752 craft_filter: String::new(),
7753 craft_filter_focused: false,
7754 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
7755 show_shop_menu: false,
7756 shop_catalog: None,
7757 bank_panel: None,
7758 bank_menu_index: 0,
7759 bank_ui_mode: BankUiMode::Menu,
7760 storage_panel: None,
7761 market_panel: None,
7762 market_menu_index: 0,
7763 market_filter: String::new(),
7764 market_filter_focused: false,
7765 market_category_filter: None,
7766 market_buy_confirm: None,
7767 market_ui_mode: MarketUiMode::Browse,
7768 storage_menu_index: 0,
7769 storage_ui_mode: StorageUiMode::Menu,
7770 shop_tab: ShopTab::default(),
7771 shop_menu_index: 0,
7772 shop_quantity: 1,
7773 shop_trade_log: VecDeque::new(),
7774 show_npc_verb_menu: false,
7775 npc_verb_target: None,
7776 npc_verb_index: 0,
7777 npc_verb_notice: None,
7778 player_verbs: crate::social::PlayerVerbState::default(),
7779 social_chat: crate::social::SocialChatState::default(),
7780 trade_ui: crate::social::TradeUiState::default(),
7781 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
7782 show_npc_chat: false,
7783 npc_chat: None,
7784 show_inventory_menu: false,
7785 inventory_menu_index: 0,
7786 inventory_tab: InventoryTab::OnPerson,
7787 inventory_filter: String::new(),
7788 inventory_filter_focused: false,
7789 show_move_picker: false,
7790 show_rename_prompt: false,
7791 rename_plot_id: None,
7792 highlighted_plot_id: None,
7793 show_worker_rename: false,
7794 rename_buffer: String::new(),
7795 move_picker_index: 0,
7796 move_picker: None,
7797 show_grant_picker: false,
7798 grant_picker_index: 0,
7799 grant_picker: None,
7800 show_destroy_picker: false,
7801 destroy_confirm_pending: false,
7802 destroy_picker: None,
7803 combat_target: None,
7804 combat_target_label: None,
7805 ground_target: None,
7806 combat_fx: Vec::new(),
7807 ground_hazards: Vec::new(),
7808 property_zones: Vec::new(),
7809 tax_zones: Vec::new(),
7810 growth_zones: Vec::new(),
7811 biome_zones: Vec::new(),
7812 terrain_kind_nav: Vec::new(),
7813 property_plots: Vec::new(),
7814 property_plot_settings: None,
7815 claim_mode: None,
7816 relocate_mode: None,
7817 sell_plot_confirm: None,
7818 sell_plot_armed_at: None,
7819 show_plant_menu: false,
7820 plant_menu_index: 0,
7821 show_farm_access: false,
7822 farm_access_name_draft: String::new(),
7823 farm_access_discount_bps: 0,
7824 farm_access_index: 0,
7825 plant_quantity: 1,
7826 in_combat: false,
7827 auto_attack: true,
7828 combat_has_los: false,
7829 attack_cd_ticks: 0,
7830 gcd_ticks: 0,
7831 weapon_ability_id: "unarmed".into(),
7832 mainhand_template_id: None,
7833 mainhand_label: None,
7834 mainhand_instance_id: None,
7835 offhand_template_id: None,
7836 offhand_label: None,
7837 offhand_instance_id: None,
7838 mainhand_hand_slots: 1,
7839 defense: None,
7840 worn: BTreeMap::new(),
7841 carry_mass: 0.0,
7842 carry_mass_max: 0.0,
7843 encumbrance: flatland_protocol::EncumbranceState::Light,
7844 move_speed_mps: 0.0,
7845 move_speed_mult: 0.0,
7846 inventory_stacks: Vec::new(),
7847 keychain_stacks: Vec::new(),
7848 whisper_pouch_stacks: Vec::new(),
7849 combat_target_detail: None,
7850 statuses: Vec::new(),
7851 cast_progress: None,
7852 timed_channel: None,
7853 plot_build_offer: None,
7854 ability_cooldowns: Vec::new(),
7855 blocking_active: false,
7856 max_target_slots: 1,
7857 combat_slots: Vec::new(),
7858 rotation_presets: Vec::new(),
7859 known_abilities: Vec::new(),
7860 ability_meta: std::collections::HashMap::new(),
7861 ability_mastery: std::collections::HashMap::new(),
7862 hotbar: vec![None; 9],
7863 max_abilities_per_rotation: 0,
7864 show_loadout_menu: false,
7865 show_keychain_menu: false,
7866 keychain_menu_index: 0,
7867 show_rotation_editor: false,
7868 loadout_menu_index: 0,
7869 loadout_hotbar_slot: 1,
7870 loadout_ability_index: 0,
7871 loadout_focus_presets: false,
7872 rotation_editor: RotationEditorState::default(),
7873 harvest_in_progress: false,
7874 harvest_started_at: None,
7875 pending_craft_ack: None,
7876 craft_channel_blueprint_id: None,
7877 pending_worker_job_ack: None,
7878 attending_worker_instance_id: None,
7879 quest_log: Vec::new(),
7880 interactables: Vec::new(),
7881 ledger: None,
7882 career: None,
7883 character_sheet_tab: CharacterSheetTab::Character,
7884 ledger_period: LedgerPeriod::Day,
7885 show_quest_offer: false,
7886 pending_quest_offers: Vec::new(),
7887 quest_offer_index: 0,
7888 show_quest_menu: false,
7889 quest_menu_index: 0,
7890 quest_withdraw_confirm: false,
7891 hired_workers: Vec::new(),
7892 show_workers_menu: false,
7893 workers_menu_index: 0,
7894 worker_dismiss_confirmation: None,
7895 workers_menu_compact: false,
7896 worker_step_display: BTreeMap::new(),
7897 worker_error_display: BTreeMap::new(),
7898 worker_health_ring_until: BTreeMap::new(),
7899 pending_worker_hire_since: None,
7900 show_worker_give_picker: false,
7901 worker_give_picker_index: 0,
7902 worker_give_picker: None,
7903 show_worker_give_target_picker: false,
7904 worker_give_target_picker_index: 0,
7905 worker_give_target_picker: None,
7906 show_worker_take_picker: false,
7907 worker_take_picker_index: 0,
7908 worker_take_picker: None,
7909 show_worker_teach_picker: false,
7910 worker_teach_picker_index: 0,
7911 worker_teach_picker: None,
7912 worker_route_editor: None,
7913 progression_curve: None,
7914 },
7915 };
7916 client.state.apply_client_ui_prefs();
7917 client
7918 }
7919
7920 pub fn entity_id(&self) -> EntityId {
7921 self.state.entity_id
7922 }
7923
7924 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
7925 if self.state.connected {
7926 return Ok(());
7927 }
7928
7929 loop {
7930 match self.session.next_event().await {
7931 Some(SessionEvent::Welcome {
7932 session_id,
7933 entity_id,
7934 snapshot,
7935 }) => {
7936 self.state
7937 .restore_from_welcome(session_id, entity_id, &snapshot);
7938 self.state.apply_client_ui_prefs();
7939 self.state.push_log(format!(
7940 "Connected — session {session_id}, entity {entity_id}"
7941 ));
7942 return Ok(());
7943 }
7944 Some(SessionEvent::Disconnected { .. }) => {
7945 anyhow::bail!("disconnected before welcome");
7946 }
7947 Some(_) => continue,
7948 None => anyhow::bail!("session closed before welcome"),
7949 }
7950 }
7951 }
7952
7953 pub fn drain_events(&mut self) {
7955 while let Some(event) = self.session.try_next_event() {
7956 if self.handle_event_sync(event).is_err() {
7957 break;
7958 }
7959 }
7960 }
7961
7962 pub async fn next_event(&mut self) -> Option<SessionEvent> {
7964 self.session.next_event().await
7965 }
7966
7967 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
7968 self.handle_event_sync(event)
7969 }
7970
7971 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
7972 match event {
7973 SessionEvent::Welcome {
7974 session_id,
7975 entity_id,
7976 snapshot,
7977 } => {
7978 let resumed = self.state.connected;
7979 self.state
7980 .restore_from_welcome(session_id, entity_id, &snapshot);
7981 if resumed {
7982 self.state.push_log(format!(
7983 "Session restored — session {session_id}, entity {entity_id}"
7984 ));
7985 }
7986 }
7987 SessionEvent::ContentUpdated { snapshot } => {
7988 self.state
7989 .apply_snapshot_fields(&snapshot, self.state.entity_id);
7990 self.state.push_log(format!(
7991 "World updated (content rev {})",
7992 snapshot.content_rev
7993 ));
7994 }
7995 SessionEvent::QuestCatalogUpdated(update) => {
7996 self.state.push_log(format!(
7997 "Quest board updated (revision {}, {} new, {} retired)",
7998 update.revision,
7999 update.accepted.len(),
8000 update.retired.len()
8001 ));
8002 }
8003 SessionEvent::Tick(delta) => {
8004 self.state.apply_tick_fields(&delta, self.state.entity_id);
8005 self.state.ticks_received += 1;
8006 }
8007 SessionEvent::IntentAck {
8008 entity_id,
8009 seq,
8010 tick,
8011 } => {
8012 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
8013 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
8014 if *craft_seq == seq {
8015 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
8016 if batches > 1 {
8017 self.state.push_log(format!("Crafting {label} ×{batches}…"));
8018 } else {
8019 self.state.push_log(format!("Crafting {label}…"));
8020 }
8021 }
8022 }
8023 if self
8024 .state
8025 .pending_worker_job_ack
8026 .as_ref()
8027 .is_some_and(|p| p.seq == seq)
8028 {
8029 let pending = self.state.pending_worker_job_ack.take().unwrap();
8030 if pending.idle {
8031 self.state.push_log(format!(
8032 "Route cleared for {} — worker idle",
8033 pending.worker_label
8034 ));
8035 } else {
8036 self.state.push_log(format!(
8037 "Route saved for {} — {} stop(s), job loop active",
8038 pending.worker_label, pending.stop_count
8039 ));
8040 }
8041 if self
8042 .state
8043 .worker_route_editor
8044 .as_ref()
8045 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
8046 {
8047 self.close_worker_route_editor();
8048 }
8049 }
8050 }
8051 SessionEvent::Chat(msg) => {
8052 let label = match msg.channel {
8053 flatland_protocol::ChatChannel::Nearby => "nearby",
8054 flatland_protocol::ChatChannel::Direct => "speak",
8055 flatland_protocol::ChatChannel::Whisper => "whisper",
8056 flatland_protocol::ChatChannel::WhisperStone => "stone",
8057 };
8058 let clarity = match msg.clarity {
8059 flatland_protocol::ChatClarity::Clear => "",
8060 flatland_protocol::ChatClarity::Partial => "~",
8061 flatland_protocol::ChatClarity::Heavy => "…",
8062 };
8063 self.state.push_log(format!(
8064 "[{label}{clarity}] {}: {}",
8065 msg.from_name, msg.text
8066 ));
8067 let now_ms = std::time::SystemTime::now()
8068 .duration_since(std::time::UNIX_EPOCH)
8069 .map(|d| d.as_millis() as u64)
8070 .unwrap_or(0);
8071 self.state
8072 .social_chat
8073 .note_speech(&msg, self.state.entity_id, now_ms);
8074 self.state
8075 .social_chat
8076 .push(crate::social::ChatLogEntry::from_message(
8077 msg,
8078 self.state.entity_id,
8079 ));
8080 }
8081 SessionEvent::TradeOpened(panel) => {
8082 self.state.social_chat.pending_trade = None;
8083 let peer = panel.peer_name.clone();
8084 self.state.trade_ui.open(panel);
8085 self.state.social_chat.push_system(format!(
8086 "Trade open with {peer} — p present · r ready · Esc cancel"
8087 ));
8088 self.state
8089 .social_chat
8090 .push_cue(crate::social::AudioCue::TradeOpened);
8091 }
8092 SessionEvent::TradeClosed { reason } => {
8093 self.state.push_log(reason.clone());
8094 self.state.social_chat.push_system(reason);
8095 self.state.trade_ui.close();
8096 }
8097 SessionEvent::HarvestResult(result) => {
8098 self.state.clear_harvest_state();
8099 crate::harvest_trace!(
8100 entity_id = self.state.entity_id,
8101 node_id = %result.node_id,
8102 template = %result.item_template,
8103 quantity = result.quantity,
8104 client_tick = self.state.tick,
8105 "client applied harvest result"
8106 );
8107 let msg = if result.quantity == 0 {
8108 format!(
8109 "Harvested {} x0 — nothing dropped (loot table rolled empty)",
8110 result.item_template
8111 )
8112 } else {
8113 format!(
8114 "Harvested {} x{} (on the ground — press P to pick up)",
8115 result.item_template, result.quantity
8116 )
8117 };
8118 self.state.push_log(msg);
8119 }
8120 SessionEvent::CraftResult(result) => {
8121 for stack in &result.consumed {
8122 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
8123 *qty = qty.saturating_sub(stack.quantity);
8124 if *qty == 0 {
8125 self.state.inventory.remove(&stack.template_id);
8126 }
8127 }
8128 }
8129 for stack in &result.outputs {
8130 *self
8131 .state
8132 .inventory
8133 .entry(stack.template_id.clone())
8134 .or_insert(0) += stack.quantity;
8135 }
8136 self.state.craft_record_completed(&result.blueprint_id);
8137 if let Some(output) = result.outputs.first() {
8138 if result.batch_total > 1 {
8139 self.state.push_log(format!(
8140 "Crafted {} x{} ({}/{})",
8141 output.template_id,
8142 output.quantity,
8143 result.batch_index,
8144 result.batch_total
8145 ));
8146 } else {
8147 self.state.push_log(format!(
8148 "Crafted {} x{}",
8149 output.template_id, output.quantity
8150 ));
8151 }
8152 } else {
8153 self.state
8154 .push_log(format!("Craft finished: {}", result.blueprint_id));
8155 }
8156 }
8157 SessionEvent::Death(notice) => {
8158 self.state.clear_harvest_state();
8159 self.state.push_log(notice.message.clone());
8160 self.state.push_log(format!(
8161 "Respawned at ({:.1}, {:.1})",
8162 notice.respawn_x, notice.respawn_y
8163 ));
8164 }
8165 SessionEvent::Interaction(notice) => {
8166 if notice.message.starts_with("Harvest failed:") {
8167 self.state.clear_harvest_state();
8168 }
8169 if notice.message.starts_with("Can't do that:") {
8170 self.state.pending_worker_hire_since = None;
8171 self.state.pending_craft_ack = None;
8172 self.state.craft_channel_blueprint_id = None;
8173 if let Some(pending) = self.state.pending_worker_job_ack.take() {
8174 if let Some(w) = self
8175 .state
8176 .hired_workers
8177 .iter_mut()
8178 .find(|w| w.instance_id == pending.worker_instance_id)
8179 {
8180 w.route = pending.prev_route;
8181 w.mode = pending.prev_mode;
8182 w.step_label = pending.prev_step_label;
8183 w.last_error = pending.prev_last_error;
8184 }
8185 let reason = notice
8186 .message
8187 .strip_prefix("Can't do that:")
8188 .unwrap_or(¬ice.message)
8189 .trim();
8190 self.state.push_log(format!(
8191 "Route save failed for {}: {reason}",
8192 pending.worker_label
8193 ));
8194 }
8195 let reason = notice
8196 .message
8197 .strip_prefix("Can't do that:")
8198 .unwrap_or(¬ice.message)
8199 .trim();
8200 if reason.contains("already tilled") {
8201 if let Some(plot) = self.state.my_plot_under_player() {
8202 self.state.sell_plot_confirm = Some(plot.plot_id);
8203 self.state.sell_plot_armed_at = Some(Instant::now());
8204 }
8205 }
8206 }
8207 if notice.message.starts_with("Cast failed:") {
8208 self.state.cast_progress = None;
8209 }
8210 if notice.message.contains("slain the") {
8211 self.state.combat_target = None;
8212 self.state.combat_target_label = None;
8213 }
8214 if notice.message.contains("wants to trade") {
8216 if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
8217 let from_name = notice
8218 .message
8219 .split(" wants to trade")
8220 .next()
8221 .unwrap_or("Player")
8222 .to_string();
8223 self.state.social_chat.pending_trade =
8224 Some(crate::social::PendingTradeRequest {
8225 from_entity,
8226 from_name: from_name.clone(),
8227 });
8228 self.state.social_chat.push_system(format!(
8229 "{from_name} wants to trade — [Y] accept · [N] decline"
8230 ));
8231 self.state
8232 .social_chat
8233 .push_cue(crate::social::AudioCue::TradeOffer);
8234 }
8235 }
8236 if notice.message.starts_with("trade request declined") {
8237 self.state.social_chat.push_system(notice.message.clone());
8238 self.state
8239 .social_chat
8240 .push_cue(crate::social::AudioCue::TradeDeclined);
8241 }
8242 if self.state.show_npc_verb_menu && !notice.message.trim().is_empty() {
8244 self.state.npc_verb_notice = Some(notice.message.clone());
8245 self.state
8246 .social_chat
8247 .push_cue(crate::social::AudioCue::UiError);
8248 }
8249 self.state.apply_interaction_notice(¬ice);
8250 self.state.push_log(notice.message.clone());
8251 }
8252 SessionEvent::ShopOpened(catalog) => {
8253 self.state.apply_shop_catalog(catalog);
8254 }
8255 SessionEvent::BankOpened(panel) => {
8256 self.state.apply_bank_panel(panel);
8257 }
8258 SessionEvent::StorageOpened(panel) => {
8259 self.state.apply_storage_panel(panel);
8260 }
8261 SessionEvent::MarketOpened(panel) => {
8262 self.state.apply_market_panel(panel);
8263 }
8264 SessionEvent::NpcTalkOpened(opened) => {
8265 self.state.show_npc_verb_menu = false;
8266 if self.state.npc_verb_target.is_none() {
8267 self.state.npc_verb_target = Some(opened.npc_id.clone());
8268 }
8269 let label = opened.npc_label.clone();
8270 let banner = if !opened.trade_allowed {
8271 Some("Trade is unavailable right now.".to_string())
8272 } else {
8273 None
8274 };
8275 self.state.show_npc_chat = true;
8276 self.state.npc_chat = Some(NpcChatState {
8277 npc_id: opened.npc_id,
8278 npc_label: opened.npc_label,
8279 lines: if opened.greeting.is_empty() {
8280 vec![]
8281 } else {
8282 vec![format!("{label}: {}", opened.greeting)]
8283 },
8284 input: String::new(),
8285 pending: opened.greeting.is_empty(),
8286 talk_depth: opened.talk_depth,
8287 trade_allowed: opened.trade_allowed,
8288 banner,
8289 suggested_topics: opened.suggested_topics,
8290 });
8291 }
8292 SessionEvent::NpcTalkPending(_) => {
8293 if let Some(chat) = self.state.npc_chat.as_mut() {
8294 chat.pending = true;
8295 }
8296 }
8297 SessionEvent::NpcTalkReply(reply) => {
8298 if let Some(chat) = self.state.npc_chat.as_mut() {
8299 if chat.npc_id == reply.npc_id {
8300 chat.pending = false;
8301 if reply.trade_disabled {
8302 chat.trade_allowed = false;
8303 chat.banner = Some("Trade is unavailable right now.".to_string());
8304 }
8305 if reply.wind_down {
8306 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
8307 if chat.banner.is_none() {
8308 chat.banner =
8309 Some("They're wrapping up — keep it brief.".to_string());
8310 }
8311 }
8312 chat.lines
8313 .push(format!("{}: {}", chat.npc_label, reply.line));
8314 }
8315 }
8316 }
8317 SessionEvent::NpcTalkClosed(closed) => {
8318 if self
8319 .state
8320 .npc_chat
8321 .as_ref()
8322 .is_some_and(|c| c.npc_id == closed.npc_id)
8323 {
8324 self.state.show_npc_chat = false;
8325 self.state.npc_chat = None;
8326 }
8327 }
8328 SessionEvent::NpcTalkError(err) => {
8329 self.state.push_log(format!("Talk failed: {}", err.reason));
8330 if let Some(chat) = self.state.npc_chat.as_mut() {
8331 chat.pending = false;
8332 }
8333 }
8334 SessionEvent::UseResult(result) => {
8335 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
8338 *qty = qty.saturating_sub(1);
8339 if *qty == 0 {
8340 self.state.inventory.remove(&result.template_id);
8341 }
8342 }
8343 }
8344 SessionEvent::QuestOffer(offer) => {
8345 let title = offer.title.clone();
8346 self.state.push_quest_offer(offer);
8347 self.state.push_log(format!("Quest offered: {title}"));
8348 }
8349 SessionEvent::QuestAccepted(notice) => {
8350 self.state.remove_quest_offer(¬ice.quest_id);
8351 self.state.push_log(notice.message);
8352 }
8353 SessionEvent::QuestWithdrawn(notice) => {
8354 self.state.show_quest_menu = false;
8355 self.state.quest_withdraw_confirm = false;
8356 self.state.push_log(notice.message);
8357 }
8358 SessionEvent::QuestStepCompleted(notice) => {
8359 self.state.push_log(notice.message);
8360 }
8361 SessionEvent::QuestCompleted(notice) => {
8362 self.state.push_log(notice.message);
8363 }
8364 SessionEvent::Disconnected { reason } => {
8365 self.state.clear_harvest_state();
8366 self.state.connected = false;
8367 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
8368 if let Some(r) = &self.state.disconnect_reason {
8369 self.state.push_log(format!("Disconnected: {r}"));
8370 } else {
8371 self.state.push_log("Disconnected from server");
8372 }
8373 }
8374 }
8375 Ok(())
8376 }
8377
8378 pub fn is_connected(&self) -> bool {
8379 self.state.connected
8380 }
8381
8382 pub fn close_overlays(&mut self) {
8383 self.state.show_stats = false;
8384 self.state.show_craft_menu = false;
8385 self.state.show_plot_build_menu = false;
8386 self.state.show_shop_menu = false;
8387 self.state.shop_catalog = None;
8388 self.state.show_npc_verb_menu = false;
8389 self.state.npc_verb_target = None;
8390 self.state.show_npc_chat = false;
8391 self.state.npc_chat = None;
8392 self.state.show_inventory_menu = false;
8393 self.state.show_loadout_menu = false;
8394 self.state.show_rotation_editor = false;
8395 self.state.rotation_editor.reset();
8396 self.state.show_rename_prompt = false;
8397 self.state.show_worker_rename = false;
8398 self.state.rename_buffer.clear();
8399 self.state.show_move_picker = false;
8400 self.state.move_picker = None;
8401 self.state.show_destroy_picker = false;
8402 self.state.destroy_confirm_pending = false;
8403 self.state.destroy_picker = None;
8404 self.state.show_quest_offer = false;
8405 self.state.clear_quest_offers();
8406 self.state.show_quest_menu = false;
8407 self.state.quest_withdraw_confirm = false;
8408 self.state.show_workers_menu = false;
8409 self.close_worker_give_picker();
8410 self.close_worker_give_target_picker();
8411 self.close_worker_take_picker();
8412 self.close_worker_teach_picker();
8413 self.state.worker_route_editor = None;
8414 self.state.claim_mode = None;
8415 self.state.relocate_mode = None;
8416 self.state.sell_plot_confirm = None;
8417 self.state.sell_plot_armed_at = None;
8418 self.close_farm_access_panel();
8419 if self.state.show_plant_menu {
8420 self.close_plant_menu();
8421 }
8422 }
8423
8424 pub fn back_on_esc(&mut self) -> bool {
8426 if self.state.social_chat.composer_open() {
8427 self.state.social_chat.close_composer();
8428 return true;
8429 }
8430 if self.state.player_verbs.open {
8431 self.state.player_verbs.close();
8432 return true;
8433 }
8434 if self.state.whisper_pouch_ui.open {
8435 self.state.whisper_pouch_ui.open = false;
8436 return true;
8437 }
8438 if self.state.trade_ui.panel.is_some() {
8439 self.state.trade_ui.close();
8441 return true;
8442 }
8443 if self.state.show_rename_prompt {
8444 self.cancel_rename_prompt();
8445 return true;
8446 }
8447 if self.state.show_worker_rename {
8448 self.cancel_worker_rename();
8449 return true;
8450 }
8451 if self.state.show_destroy_picker {
8452 if self.state.destroy_confirm_pending {
8453 self.cancel_destroy_confirm();
8454 } else {
8455 self.close_destroy_picker();
8456 }
8457 return true;
8458 }
8459 if self.state.claim_mode.is_some() {
8460 self.cancel_claim_mode();
8461 return true;
8462 }
8463 if self.state.relocate_mode.is_some() {
8464 self.cancel_relocate_mode();
8465 return true;
8466 }
8467 if self.state.show_plant_menu {
8468 self.close_plant_menu();
8469 return true;
8470 }
8471 if self.state.show_farm_access {
8472 self.close_farm_access_panel();
8473 return true;
8474 }
8475 if self.state.sell_plot_confirm.is_some() {
8476 self.state.sell_plot_confirm = None;
8477 self.state.sell_plot_armed_at = None;
8478 self.state.push_log("Sell cancelled");
8479 return true;
8480 }
8481 if self.state.show_move_picker {
8482 self.close_move_picker();
8483 return true;
8484 }
8485 if self.state.show_rotation_editor {
8486 match self.state.rotation_editor.mode {
8487 RotationEditorMode::List => {
8488 self.state.show_rotation_editor = false;
8489 self.state.rotation_editor.reset();
8490 }
8491 RotationEditorMode::EditLabel => {
8492 self.state.rotation_editor.label_buffer.clear();
8493 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8494 }
8495 RotationEditorMode::PickAbility => {
8496 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8497 }
8498 RotationEditorMode::EditSequence => {
8499 self.state.rotation_editor.draft = None;
8500 self.state.rotation_editor.mode = RotationEditorMode::List;
8501 }
8502 }
8503 return true;
8504 }
8505 if self.state.show_inventory_menu {
8506 self.close_inventory_menu();
8507 return true;
8508 }
8509 if self.state.show_craft_menu {
8510 self.close_craft_menu();
8511 return true;
8512 }
8513 if self.state.show_plot_build_menu {
8514 self.close_plot_build_menu();
8515 return true;
8516 }
8517 if self.state.show_keychain_menu {
8518 self.close_keychain_menu();
8519 return true;
8520 }
8521 if self.state.show_quest_offer {
8522 self.quest_offer_decline();
8523 return true;
8524 }
8525 if self.state.show_shop_menu {
8526 return false;
8528 }
8529 if self.state.bank_panel.is_some() {
8530 return false;
8531 }
8532 if self.state.storage_panel.is_some() {
8533 return false;
8534 }
8535 if self.state.market_panel.is_some() {
8536 return false;
8537 }
8538 if self.state.show_npc_chat {
8539 return false;
8541 }
8542 if self.state.show_npc_verb_menu {
8543 self.state.show_npc_verb_menu = false;
8544 self.state.npc_verb_target = None;
8545 self.state.npc_verb_notice = None;
8546 return true;
8547 }
8548 if self.state.show_quest_menu {
8549 if self.state.quest_withdraw_confirm {
8550 self.state.quest_withdraw_confirm = false;
8551 } else {
8552 self.state.show_quest_menu = false;
8553 }
8554 return true;
8555 }
8556 if self.state.worker_route_editor.is_some() {
8557 if self.re_at_root_sheet() {
8559 let reopen = self.state.attending_worker_instance_id.clone();
8560 self.close_worker_route_editor();
8561 if let Some(id) = reopen {
8562 if let Some(idx) = self
8563 .state
8564 .hired_workers
8565 .iter()
8566 .position(|w| w.instance_id == id)
8567 {
8568 self.state.workers_menu_index = idx;
8569 self.state.show_workers_menu = true;
8570 }
8571 }
8572 } else {
8573 self.re_sheet_back();
8574 }
8575 return true;
8576 }
8577 if self.state.show_worker_give_picker {
8578 self.close_worker_give_picker();
8579 return true;
8580 }
8581 if self.state.show_worker_give_target_picker {
8582 self.close_worker_give_target_picker();
8583 return true;
8584 }
8585 if self.state.show_worker_take_picker {
8586 self.close_worker_take_picker();
8587 return true;
8588 }
8589 if self.state.show_worker_teach_picker {
8590 self.close_worker_teach_picker();
8591 return true;
8592 }
8593 if self.state.show_workers_menu {
8594 self.close_workers_menu_ui();
8595 return true;
8596 }
8597 if self.state.show_loadout_menu {
8598 self.state.show_loadout_menu = false;
8599 return true;
8600 }
8601 if self.state.show_stats {
8602 self.state.show_stats = false;
8603 return true;
8604 }
8605 if self.state.show_equip_menu {
8606 self.state.show_equip_menu = false;
8607 return true;
8608 }
8609 false
8610 }
8611
8612 pub fn toggle_stats(&mut self) {
8613 self.state.show_stats = !self.state.show_stats;
8614 if self.state.show_stats {
8615 self.state.character_sheet_tab = CharacterSheetTab::Character;
8616 self.state.show_craft_menu = false;
8617 self.state.show_shop_menu = false;
8618 self.state.shop_catalog = None;
8619 self.state.show_inventory_menu = false;
8620 self.state.show_equip_menu = false;
8621 }
8622 }
8623
8624 pub fn toggle_equip_menu(&mut self) {
8625 self.state.show_equip_menu = !self.state.show_equip_menu;
8626 if self.state.show_equip_menu {
8627 self.state.show_stats = false;
8628 self.state.show_craft_menu = false;
8629 self.state.show_shop_menu = false;
8630 self.state.shop_catalog = None;
8631 self.state.show_inventory_menu = false;
8632 self.state.show_loadout_menu = false;
8633 }
8634 }
8635
8636 pub fn cycle_character_sheet_tab(&mut self) {
8637 if self.state.show_stats {
8638 self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
8639 }
8640 }
8641
8642 pub fn set_ledger_period_digit(&mut self, c: char) {
8643 if self.state.show_stats {
8644 if let Some(p) = LedgerPeriod::from_digit(c) {
8645 self.state.ledger_period = p;
8646 self.state.character_sheet_tab = CharacterSheetTab::Ledger;
8647 }
8648 }
8649 }
8650
8651 pub fn cycle_ledger_period(&mut self) {
8652 if self.state.show_stats && self.state.character_sheet_tab == CharacterSheetTab::Ledger {
8653 self.state.ledger_period = self.state.ledger_period.cycle();
8654 }
8655 }
8656
8657 pub fn open_inventory_menu(&mut self) {
8658 self.state.show_inventory_menu = true;
8659 self.state.show_craft_menu = false;
8660 self.state.show_shop_menu = false;
8661 self.state.shop_catalog = None;
8662 self.state.show_stats = false;
8663 self.state.show_move_picker = false;
8664 self.state.move_picker = None;
8665 self.state.show_destroy_picker = false;
8666 self.state.destroy_confirm_pending = false;
8667 self.state.destroy_picker = None;
8668 self.state.show_rename_prompt = false;
8669 self.state.rename_plot_id = None;
8670 self.state.rename_buffer.clear();
8671 self.state.inventory_filter_focused = false;
8672 self.state.clamp_inventory_indices();
8673 }
8674
8675 pub fn close_inventory_menu(&mut self) {
8676 self.state.show_inventory_menu = false;
8677 self.state.show_move_picker = false;
8678 self.state.move_picker = None;
8679 self.close_grant_picker();
8680 self.state.show_destroy_picker = false;
8681 self.state.destroy_confirm_pending = false;
8682 self.state.destroy_picker = None;
8683 self.state.show_rename_prompt = false;
8684 self.state.rename_plot_id = None;
8685 self.state.rename_buffer.clear();
8686 self.state.inventory_filter_focused = false;
8687 }
8688
8689 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
8690 let Some(row) = self.state.inventory_selected_row() else {
8691 anyhow::bail!("inventory empty");
8692 };
8693 if GameState::is_property_deed_template(&row.stack.template_id) {
8694 let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
8695 anyhow::bail!("deed has no plot id");
8696 };
8697 let label = self
8698 .state
8699 .property_plots
8700 .iter()
8701 .find(|p| p.plot_id == plot_id)
8702 .map(|p| {
8703 if p.label.trim().is_empty() {
8704 p.plot_code.clone()
8705 } else {
8706 p.label.clone()
8707 }
8708 })
8709 .unwrap_or_else(|| {
8710 row.stack
8711 .display_name
8712 .clone()
8713 .unwrap_or_else(|| "plot".into())
8714 });
8715 self.state.rename_buffer = label;
8716 self.state.rename_plot_id = Some(plot_id);
8717 self.state.highlighted_plot_id = Some(plot_id);
8718 self.state.show_rename_prompt = true;
8719 self.state.show_worker_rename = false;
8720 self.state.show_move_picker = false;
8721 self.state.show_destroy_picker = false;
8722 self.state.destroy_confirm_pending = false;
8723 return Ok(());
8724 }
8725 if !self.state.row_is_renameable_container(&row) {
8726 anyhow::bail!("only storage containers or deeds can be renamed");
8727 }
8728 let current = row
8729 .stack
8730 .display_name
8731 .clone()
8732 .unwrap_or_else(|| row.stack.template_id.clone());
8733 self.state.rename_buffer = current;
8734 self.state.rename_plot_id = None;
8735 self.state.show_rename_prompt = true;
8736 self.state.show_worker_rename = false;
8737 self.state.show_move_picker = false;
8738 self.state.show_destroy_picker = false;
8739 self.state.destroy_confirm_pending = false;
8740 Ok(())
8741 }
8742
8743 pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
8745 let Some(plot) = self.state.my_plot_under_player().cloned() else {
8746 anyhow::bail!("stand on your plot to rename it");
8747 };
8748 let label = if plot.label.trim().is_empty() {
8749 plot.plot_code.clone()
8750 } else {
8751 plot.label.clone()
8752 };
8753 self.state.rename_buffer = label;
8754 self.state.rename_plot_id = Some(plot.plot_id);
8755 self.state.highlighted_plot_id = Some(plot.plot_id);
8756 self.state.show_rename_prompt = true;
8757 self.state.show_worker_rename = false;
8758 Ok(())
8759 }
8760
8761 pub fn cancel_rename_prompt(&mut self) {
8762 self.state.show_rename_prompt = false;
8763 self.state.rename_plot_id = None;
8764 self.state.rename_buffer.clear();
8765 }
8766
8767 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
8768 let name = self.state.rename_buffer.trim().to_string();
8769 if name.is_empty() {
8770 anyhow::bail!("name cannot be empty");
8771 }
8772 if let Some(plot_id) = self.state.rename_plot_id {
8773 if name.chars().count() > 48 {
8774 anyhow::bail!("label must be 1–48 characters");
8775 }
8776 self.seq += 1;
8777 self.session
8778 .submit_intent(Intent::RenamePropertyPlot {
8779 entity_id: self.state.entity_id,
8780 plot_id,
8781 label: name,
8782 seq: self.seq,
8783 })
8784 .await?;
8785 self.state.intents_sent += 1;
8786 self.state.show_rename_prompt = false;
8787 self.state.rename_plot_id = None;
8788 self.state.rename_buffer.clear();
8789 return Ok(());
8790 }
8791 if name.chars().count() > 32 {
8792 anyhow::bail!("name must be 1–32 characters");
8793 }
8794 let Some(row) = self.state.inventory_selected_row() else {
8795 anyhow::bail!("inventory empty");
8796 };
8797 let Some(instance_id) = row.stack.item_instance_id else {
8798 anyhow::bail!("item has no instance id");
8799 };
8800 self.seq += 1;
8801 self.session
8802 .submit_intent(Intent::RenameContainer {
8803 entity_id: self.state.entity_id,
8804 item_instance_id: instance_id,
8805 location: row.from.clone(),
8806 name,
8807 seq: self.seq,
8808 })
8809 .await?;
8810 self.state.intents_sent += 1;
8811 self.state.show_rename_prompt = false;
8812 self.state.rename_buffer.clear();
8813 Ok(())
8814 }
8815
8816 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
8817 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8818 anyhow::bail!("no worker selected");
8819 };
8820 self.state.rename_buffer = worker.label.clone();
8821 self.state.show_worker_rename = true;
8822 self.state.show_rename_prompt = false;
8823 Ok(())
8824 }
8825
8826 pub fn cancel_worker_rename(&mut self) {
8827 self.state.show_worker_rename = false;
8828 self.state.rename_buffer.clear();
8829 }
8830
8831 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
8832 let name = self.state.rename_buffer.trim().to_string();
8833 if name.is_empty() {
8834 anyhow::bail!("name cannot be empty");
8835 }
8836 if name.chars().count() > 32 {
8837 anyhow::bail!("name must be 1–32 characters");
8838 }
8839 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8840 anyhow::bail!("no worker selected");
8841 };
8842 let worker_instance_id = worker.instance_id.clone();
8843 self.seq += 1;
8844 self.session
8845 .submit_intent(Intent::RenameHiredWorker {
8846 entity_id: self.state.entity_id,
8847 worker_instance_id: worker_instance_id.clone(),
8848 name: name.clone(),
8849 seq: self.seq,
8850 })
8851 .await?;
8852 self.state.intents_sent += 1;
8853 if let Some(w) = self
8854 .state
8855 .hired_workers
8856 .iter_mut()
8857 .find(|w| w.instance_id == worker_instance_id)
8858 {
8859 w.label = name.clone();
8860 }
8861 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8862 if ed.worker_instance_id == worker_instance_id {
8863 ed.worker_label = name.clone();
8864 }
8865 }
8866 self.state.show_worker_rename = false;
8867 self.state.rename_buffer.clear();
8868 self.state.push_log(format!("Renamed worker to \"{name}\""));
8869 Ok(())
8870 }
8871
8872 pub fn toggle_inventory_menu(&mut self) {
8873 if self.state.show_inventory_menu {
8874 self.close_inventory_menu();
8875 } else {
8876 self.open_inventory_menu();
8877 }
8878 }
8879
8880 pub fn inventory_menu_move(&mut self, delta: i32) {
8882 if self.state.show_grant_picker {
8883 let Some(picker) = self.state.grant_picker.as_ref() else {
8884 return;
8885 };
8886 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8887 let filter = picker.filter.clone();
8888 let n = labels.len();
8889 if n == 0 {
8890 return;
8891 }
8892 self.state.grant_picker_index =
8893 step_filtered_index(self.state.grant_picker_index, delta, n, |i| {
8894 list_label_matches(&labels[i], &filter)
8895 });
8896 return;
8897 }
8898 if self.state.show_move_picker {
8899 let Some(picker) = self.state.move_picker.as_ref() else {
8900 return;
8901 };
8902 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8903 let filter = picker.filter.clone();
8904 let n = labels.len();
8905 if n == 0 {
8906 return;
8907 }
8908 self.state.move_picker_index =
8909 step_filtered_index(self.state.move_picker_index, delta, n, |i| {
8910 list_label_matches(&labels[i], &filter)
8911 });
8912 self.state.clamp_move_picker_quantity();
8913 return;
8914 }
8915 let n = self.state.inventory_selectable_rows().len();
8916 if n == 0 {
8917 return;
8918 }
8919 let idx = self.state.inventory_menu_index as i32;
8920 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8921 }
8922
8923 pub fn inventory_menu_page(&mut self, pages: i32) {
8925 if self.state.show_grant_picker {
8926 let Some(picker) = self.state.grant_picker.as_ref() else {
8927 return;
8928 };
8929 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8930 let filter = picker.filter.clone();
8931 let n = labels.len();
8932 self.state.grant_picker_index =
8933 page_filtered_index(self.state.grant_picker_index, pages, n, |i| {
8934 list_label_matches(&labels[i], &filter)
8935 });
8936 return;
8937 }
8938 if self.state.show_move_picker {
8939 let Some(picker) = self.state.move_picker.as_ref() else {
8940 return;
8941 };
8942 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8943 let filter = picker.filter.clone();
8944 let n = labels.len();
8945 self.state.move_picker_index =
8946 page_filtered_index(self.state.move_picker_index, pages, n, |i| {
8947 list_label_matches(&labels[i], &filter)
8948 });
8949 self.state.clamp_move_picker_quantity();
8950 return;
8951 }
8952 let n = self.state.inventory_selectable_rows().len();
8953 self.state.inventory_menu_index =
8954 page_list_index(self.state.inventory_menu_index, pages, n);
8955 }
8956
8957 pub fn cycle_inventory_tab(&mut self, forward: bool) {
8958 if self.state.show_move_picker
8959 || self.state.show_grant_picker
8960 || self.state.show_destroy_picker
8961 || self.state.show_rename_prompt
8962 || self.state.inventory_filter_focused
8963 {
8964 return;
8965 }
8966 self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
8967 self.state.inventory_menu_index = 0;
8968 self.state.clamp_inventory_indices();
8969 }
8970
8971 pub fn focus_inventory_filter(&mut self) {
8972 if self.state.show_grant_picker {
8973 if let Some(p) = self.state.grant_picker.as_mut() {
8974 p.filter_focused = true;
8975 }
8976 return;
8977 }
8978 if self.state.show_move_picker {
8979 if let Some(p) = self.state.move_picker.as_mut() {
8980 p.filter_focused = true;
8981 }
8982 return;
8983 }
8984 self.state.inventory_filter_focused = true;
8985 }
8986
8987 pub fn set_inventory_filter(&mut self, filter: String) {
8988 self.state.inventory_filter = filter;
8989 self.state.inventory_menu_index = 0;
8990 self.state.clamp_inventory_indices();
8991 }
8992
8993 pub fn append_inventory_filter_char(&mut self, ch: char) {
8994 if !is_list_filter_char(ch) {
8995 return;
8996 }
8997 if self.state.show_grant_picker {
8998 if let Some(p) = self.state.grant_picker.as_mut() {
8999 if p.filter_focused {
9000 p.filter.push(ch);
9001 self.state.grant_picker_index = 0;
9002 }
9003 }
9004 return;
9005 }
9006 if self.state.show_move_picker {
9007 if let Some(p) = self.state.move_picker.as_mut() {
9008 if p.filter_focused {
9009 p.filter.push(ch);
9010 self.state.move_picker_index = 0;
9011 self.state.clamp_move_picker_quantity();
9012 }
9013 }
9014 return;
9015 }
9016 if !self.state.inventory_filter_focused {
9017 return;
9018 }
9019 self.state.inventory_filter.push(ch);
9020 self.state.inventory_menu_index = 0;
9021 self.state.clamp_inventory_indices();
9022 }
9023
9024 pub fn inventory_filter_backspace(&mut self) {
9025 if self.state.show_grant_picker {
9026 if let Some(p) = self.state.grant_picker.as_mut() {
9027 if p.filter_focused {
9028 p.filter.pop();
9029 self.state.grant_picker_index = 0;
9030 }
9031 }
9032 return;
9033 }
9034 if self.state.show_move_picker {
9035 if let Some(p) = self.state.move_picker.as_mut() {
9036 if p.filter_focused {
9037 p.filter.pop();
9038 self.state.move_picker_index = 0;
9039 self.state.clamp_move_picker_quantity();
9040 }
9041 }
9042 return;
9043 }
9044 if !self.state.inventory_filter_focused {
9045 return;
9046 }
9047 self.state.inventory_filter.pop();
9048 self.state.inventory_menu_index = 0;
9049 self.state.clamp_inventory_indices();
9050 }
9051
9052 pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
9054 if self.state.show_grant_picker {
9055 if let Some(p) = self.state.grant_picker.as_mut() {
9056 if p.filter_focused {
9057 if !p.filter.is_empty() {
9058 p.filter.clear();
9059 self.state.grant_picker_index = 0;
9060 } else {
9061 p.filter_focused = false;
9062 }
9063 return true;
9064 }
9065 if !p.filter.is_empty() {
9066 p.filter.clear();
9067 self.state.grant_picker_index = 0;
9068 return true;
9069 }
9070 }
9071 return false;
9072 }
9073 if self.state.show_move_picker {
9074 if let Some(p) = self.state.move_picker.as_mut() {
9075 if p.filter_focused {
9076 if !p.filter.is_empty() {
9077 p.filter.clear();
9078 self.state.move_picker_index = 0;
9079 self.state.clamp_move_picker_quantity();
9080 } else {
9081 p.filter_focused = false;
9082 }
9083 return true;
9084 }
9085 if !p.filter.is_empty() {
9086 p.filter.clear();
9087 self.state.move_picker_index = 0;
9088 self.state.clamp_move_picker_quantity();
9089 return true;
9090 }
9091 }
9092 return false;
9093 }
9094 if self.state.inventory_filter_focused {
9095 if !self.state.inventory_filter.is_empty() {
9096 self.state.inventory_filter.clear();
9097 self.state.inventory_menu_index = 0;
9098 self.state.clamp_inventory_indices();
9099 } else {
9100 self.state.inventory_filter_focused = false;
9101 }
9102 return true;
9103 }
9104 if !self.state.inventory_filter.is_empty() {
9105 self.state.inventory_filter.clear();
9106 self.state.inventory_menu_index = 0;
9107 self.state.clamp_inventory_indices();
9108 return true;
9109 }
9110 false
9111 }
9112
9113 pub fn craft_menu_page(&mut self, pages: i32) {
9114 let n = self.state.craft_filtered_indices().len();
9115 self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
9116 self.state.clamp_craft_batch_quantity();
9117 }
9118
9119 pub fn shop_menu_page(&mut self, pages: i32) {
9120 let n = self.state.shop_list_len();
9121 self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
9122 self.state.clamp_shop_quantity();
9123 }
9124
9125 pub fn workers_menu_page(&mut self, pages: i32) {
9126 let n = self.state.hired_workers.len();
9127 self.state.workers_menu_index = page_list_index(self.state.workers_menu_index, pages, n);
9128 }
9129
9130 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
9135 if self.state.show_destroy_picker {
9136 if self.state.destroy_confirm_pending {
9137 return self.confirm_destroy_item().await;
9138 }
9139 return self.request_destroy_confirm();
9140 }
9141 if self.state.show_grant_picker {
9142 return self.confirm_grant_picker().await;
9143 }
9144 if self.state.show_move_picker {
9145 return self.confirm_move_picker().await;
9146 }
9147 let Some(row) = self.state.inventory_selected_row() else {
9148 anyhow::bail!("inventory empty");
9149 };
9150 if row.is_equip_shell {
9151 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
9152 anyhow::bail!("not a worn item");
9153 };
9154 return self.equip_worn(slot, None).await;
9155 }
9156 if row.is_chest_shell {
9157 return self.open_chest_pickup_picker();
9158 }
9159 let template_id = row.stack.template_id.clone();
9160 let instance_id = row.stack.item_instance_id;
9161 let category = self.state.inventory_item_category(&template_id);
9162 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9163
9164 if category == Some("weapon") {
9165 return self.equip_mainhand(Some(template_id)).await;
9166 }
9167 if category == Some("lodging") && on_person {
9168 if let Some(inst) = instance_id {
9169 return self.place_container(inst).await;
9170 }
9171 }
9172 if on_person {
9174 if let Some(inst) = instance_id {
9175 if row.stack.world_placeable == Some(true) {
9176 return self.place_container(inst).await;
9177 }
9178 }
9179 }
9180 if (category == Some("container") || category == Some("armor")) && on_person {
9181 if let Some(inst) = instance_id {
9182 let world_placeable =
9183 row.stack.world_placeable == Some(true) || template_id.contains("chest");
9184 if world_placeable {
9185 return self.place_container(inst).await;
9186 }
9187 if let Some(slot) = guess_body_slot(&template_id) {
9191 return self.equip_worn(slot, Some(inst)).await;
9192 }
9193 }
9194 }
9195 self.open_move_picker()
9199 }
9200
9201 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
9203 let Some(row) = self.state.inventory_selected_row() else {
9204 anyhow::bail!("inventory empty");
9205 };
9206 if row.from != flatland_protocol::InventoryLocation::Root {
9207 anyhow::bail!("select a consumable on your person");
9208 }
9209 if GameState::stack_is_item_grant(&row.stack) {
9210 return self.open_grant_target_picker();
9211 }
9212 if GameState::is_property_deed_template(&row.stack.template_id) {
9213 return self.open_move_picker();
9214 }
9215 let category = self.state.inventory_item_category(&row.stack.template_id);
9216 if category != Some("consumable") && !GameState::stack_is_serving(&row.stack) {
9217 anyhow::bail!("selected item is not usable");
9218 }
9219 self.use_item(&row.stack.template_id).await
9220 }
9221
9222 pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
9224 let Some(row) = self.state.inventory_selected_row() else {
9225 anyhow::bail!("inventory empty");
9226 };
9227 if row.from != flatland_protocol::InventoryLocation::Root {
9228 anyhow::bail!("select a grant item on your person");
9229 }
9230 if !GameState::stack_is_item_grant(&row.stack) {
9231 anyhow::bail!("selected item does not grant onto gear");
9232 }
9233 let Some(grant_instance_id) = row.stack.item_instance_id else {
9234 anyhow::bail!("grant has no instance id");
9235 };
9236 let effect_id = GameState::grant_effect_id(&row.stack)
9237 .unwrap_or("?")
9238 .to_string();
9239 let mode = GameState::grant_mode(&row.stack).to_string();
9240 let options = self.state.grant_target_options(&row.stack);
9241 if options.is_empty() {
9242 anyhow::bail!("no valid gear to apply {effect_id} to");
9243 }
9244 let grant_label = row
9245 .stack
9246 .display_name
9247 .clone()
9248 .unwrap_or_else(|| row.stack.template_id.clone());
9249 self.state.show_grant_picker = true;
9250 self.state.grant_picker_index = 0;
9251 self.state.grant_picker = Some(GrantTargetPicker {
9252 grant_instance_id,
9253 grant_label,
9254 effect_id,
9255 mode,
9256 options,
9257 filter: String::new(),
9258 filter_focused: false,
9259 });
9260 Ok(())
9261 }
9262
9263 pub fn close_grant_picker(&mut self) {
9264 self.state.show_grant_picker = false;
9265 self.state.grant_picker = None;
9266 self.state.grant_picker_index = 0;
9267 }
9268
9269 pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
9270 let Some(picker) = self.state.grant_picker.clone() else {
9271 self.close_grant_picker();
9272 return Ok(());
9273 };
9274 let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
9275 self.close_grant_picker();
9276 return Ok(());
9277 };
9278 self.close_grant_picker();
9279 self.use_grant(picker.grant_instance_id, opt.target_instance_id)
9280 .await?;
9281 self.state
9282 .push_log(format!("Applying {} onto {}…", picker.effect_id, opt.label));
9283 Ok(())
9284 }
9285
9286 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
9290 let Some(row) = self.state.inventory_selected_row() else {
9291 anyhow::bail!("inventory empty");
9292 };
9293 if row.is_equip_shell {
9294 anyhow::bail!("this is a worn bag — press Enter to unequip it");
9295 }
9296 if row.is_chest_shell {
9297 return self.open_chest_pickup_picker();
9298 }
9299 let Some(instance_id) = row.stack.item_instance_id else {
9300 anyhow::bail!("item has no instance id");
9301 };
9302 let mut options = self.state.move_destinations_for(
9303 &row.from,
9304 row.from_parent_instance_id,
9305 row.stack.item_instance_id,
9306 &row.stack.template_id,
9307 );
9308 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9309 let category = self.state.inventory_item_category(&row.stack.template_id);
9310 if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
9311 if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
9312 options.insert(
9313 0,
9314 MoveOption {
9315 label: "Sell plot to crown…".into(),
9316 kind: MoveOptionKind::SellPlotToCrown { plot_id },
9317 },
9318 );
9319 }
9320 }
9321 if on_person && category == Some("consumable") {
9322 if GameState::stack_is_item_grant(&row.stack) {
9323 options.insert(
9324 0,
9325 MoveOption {
9326 label: "Apply onto gear…".into(),
9327 kind: MoveOptionKind::GrantApply,
9328 },
9329 );
9330 } else {
9331 let study = GameState::stack_is_blueprint_scroll(&row.stack);
9332 options.insert(
9333 0,
9334 MoveOption {
9335 label: if study {
9336 "Study".into()
9337 } else {
9338 "Use (eat / drink)".into()
9339 },
9340 kind: MoveOptionKind::Use,
9341 },
9342 );
9343 }
9344 } else if on_person && GameState::stack_is_serving(&row.stack) {
9345 let label = if GameState::stack_is_food_serving(&row.stack) {
9346 "Use (eat)"
9347 } else {
9348 "Use (fill / drink)"
9349 };
9350 options.insert(
9351 0,
9352 MoveOption {
9353 label: label.into(),
9354 kind: MoveOptionKind::Use,
9355 },
9356 );
9357 }
9358 let item_label = row
9359 .stack
9360 .display_name
9361 .clone()
9362 .unwrap_or_else(|| row.stack.template_id.clone());
9363 let initial_qty = if row.stack.quantity > 1 {
9366 1
9367 } else {
9368 row.stack.quantity
9369 };
9370 self.state.move_picker = Some(MovePicker {
9371 item_instance_id: instance_id,
9372 from: row.from,
9373 item_label,
9374 template_id: row.stack.template_id.clone(),
9375 stack_quantity: row.stack.quantity,
9376 quantity: initial_qty.max(1),
9377 options,
9378 filter: String::new(),
9379 filter_focused: false,
9380 });
9381 self.state.move_picker_index = 0;
9382 self.state.show_move_picker = true;
9383 self.state.show_destroy_picker = false;
9384 self.state.destroy_confirm_pending = false;
9385 self.state.destroy_picker = None;
9386 self.state.clamp_move_picker_quantity();
9387 Ok(())
9388 }
9389
9390 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
9392 let Some(row) = self.state.inventory_selected_row() else {
9393 anyhow::bail!("inventory empty");
9394 };
9395 if !row.is_chest_shell {
9396 anyhow::bail!("not a placed chest");
9397 }
9398 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
9399 anyhow::bail!("not a placed chest");
9400 };
9401 let Some(instance_id) = row.stack.item_instance_id else {
9402 anyhow::bail!("chest has no instance id");
9403 };
9404 let chest = self
9405 .state
9406 .placed_containers
9407 .iter()
9408 .find(|c| c.id == *container_id)
9409 .cloned()
9410 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9411 let (px, py) = self.state.player_position();
9412 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9413 anyhow::bail!("too far from {}", chest.display_name);
9414 }
9415 if chest.locked && !chest.accessible {
9416 anyhow::bail!(
9417 "need the matching key for {} before picking it up",
9418 chest.display_name
9419 );
9420 }
9421 let options = self.state.chest_pickup_destinations(container_id);
9422 let item_label = row
9423 .stack
9424 .display_name
9425 .clone()
9426 .unwrap_or_else(|| row.stack.template_id.clone());
9427 self.state.move_picker = Some(MovePicker {
9428 item_instance_id: instance_id,
9429 from: row.from.clone(),
9430 item_label,
9431 template_id: row.stack.template_id.clone(),
9432 stack_quantity: 1,
9433 quantity: 1,
9434 options,
9435 filter: String::new(),
9436 filter_focused: false,
9437 });
9438 self.state.move_picker_index = 0;
9439 self.state.show_move_picker = true;
9440 self.state.show_destroy_picker = false;
9441 self.state.destroy_confirm_pending = false;
9442 self.state.destroy_picker = None;
9443 Ok(())
9444 }
9445
9446 pub fn close_move_picker(&mut self) {
9447 self.state.show_move_picker = false;
9448 self.state.move_picker = None;
9449 }
9450
9451 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
9452 self.state.move_picker_adjust_quantity(delta);
9453 }
9454
9455 pub fn move_picker_set_quantity_max(&mut self) {
9456 self.state.move_picker_set_quantity_max();
9457 }
9458
9459 pub fn move_picker_set_quantity_min(&mut self) {
9460 self.state.move_picker_set_quantity_min();
9461 }
9462
9463 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
9464 self.state.destroy_picker_adjust_quantity(delta);
9465 }
9466
9467 pub fn destroy_picker_set_quantity_max(&mut self) {
9468 self.state.destroy_picker_set_quantity_max();
9469 }
9470
9471 pub fn destroy_picker_set_quantity_min(&mut self) {
9472 self.state.destroy_picker_set_quantity_min();
9473 }
9474
9475 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
9476 let Some(picker) = self.state.move_picker.clone() else {
9477 self.close_move_picker();
9478 return Ok(());
9479 };
9480 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
9481 self.close_move_picker();
9482 return Ok(());
9483 };
9484 match option.kind {
9485 MoveOptionKind::Cancel => {
9486 self.close_move_picker();
9487 }
9488 MoveOptionKind::Use => {
9489 self.close_move_picker();
9490 self.use_item(&picker.template_id).await?;
9491 }
9492 MoveOptionKind::GrantApply => {
9493 self.close_move_picker();
9494 self.open_grant_target_picker()?;
9495 }
9496 MoveOptionKind::SellPlotToCrown { plot_id } => {
9497 self.close_move_picker();
9498 self.confirm_sell_plot_to_crown(plot_id).await?;
9499 }
9500 MoveOptionKind::RelocatePlaced { container_id } => {
9501 self.close_move_picker();
9502 self.state.show_inventory_menu = false;
9503 self.begin_relocate_container(&container_id)?;
9504 }
9505 MoveOptionKind::Drop => {
9506 self.close_move_picker();
9507 if self
9508 .state
9509 .hand_equipped_instance_ids()
9510 .contains(&picker.item_instance_id)
9511 {
9512 anyhow::bail!("unequip that item first");
9513 }
9514 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
9515 if self.state.deed_bound(&stack) {
9516 anyhow::bail!(
9517 "cannot drop a property deed — store it or trade it to another player"
9518 );
9519 }
9520 if self.state.key_drop_blocked(&stack) {
9521 anyhow::bail!("cannot drop the key while its chest is locked");
9522 }
9523 }
9524 self.drop_item(picker.item_instance_id, picker.from).await?;
9525 self.state
9526 .push_log(format!("Dropped {}", picker.item_label));
9527 }
9528 MoveOptionKind::PickupPlaced {
9529 container_id,
9530 nest_location,
9531 nest_parent_instance_id,
9532 } => {
9533 self.close_move_picker();
9534 self.pickup_container(container_id.clone()).await?;
9535 let nest_into_bag = nest_parent_instance_id.is_some()
9536 || !matches!(nest_location, flatland_protocol::InventoryLocation::Root);
9537 if nest_into_bag {
9538 self.move_item(
9539 picker.item_instance_id,
9540 flatland_protocol::InventoryLocation::Root,
9541 nest_location,
9542 nest_parent_instance_id,
9543 None,
9544 )
9545 .await?;
9546 self.state
9547 .push_log(format!("Picked up {} into bag", picker.item_label));
9548 } else {
9549 self.state
9550 .push_log(format!("Picked up {}", picker.item_label));
9551 }
9552 }
9553 MoveOptionKind::Move {
9554 location,
9555 parent_instance_id,
9556 } => {
9557 self.close_move_picker();
9558 let qty = if picker.quantity >= picker.stack_quantity {
9559 None
9560 } else {
9561 Some(picker.quantity)
9562 };
9563 self.move_item(
9564 picker.item_instance_id,
9565 picker.from,
9566 location,
9567 parent_instance_id,
9568 qty,
9569 )
9570 .await?;
9571 let moved = qty.unwrap_or(picker.stack_quantity);
9572 if moved >= picker.stack_quantity {
9573 self.state.push_log(format!("Moved {}", picker.item_label));
9574 } else {
9575 self.state.push_log(format!(
9576 "Moved {} ×{} of {}",
9577 picker.item_label, moved, picker.stack_quantity
9578 ));
9579 }
9580 }
9581 }
9582 Ok(())
9583 }
9584
9585 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
9589 let Some(row) = self.state.inventory_selected_row() else {
9590 anyhow::bail!("inventory empty");
9591 };
9592 if row.is_equip_shell {
9593 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
9594 }
9595 if row.is_chest_shell {
9596 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
9597 }
9598 let Some(inst) = row.stack.item_instance_id else {
9599 anyhow::bail!("item has no instance id");
9600 };
9601 if self.state.hand_equipped_instance_ids().contains(&inst) {
9602 anyhow::bail!("unequip that item first");
9603 }
9604 if self.state.deed_bound(&row.stack) {
9605 anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
9606 }
9607 if self.state.key_drop_blocked(&row.stack) {
9608 anyhow::bail!("cannot drop the key while its chest is locked");
9609 }
9610 let label = row
9611 .stack
9612 .display_name
9613 .clone()
9614 .unwrap_or_else(|| row.stack.template_id.clone());
9615 let placeable = row.stack.world_placeable == Some(true)
9616 || row.from == flatland_protocol::InventoryLocation::Root
9617 && matches!(
9618 self.state.inventory_item_category(&row.stack.template_id).as_deref(),
9619 Some("lodging")
9620 );
9621 if placeable && row.from == flatland_protocol::InventoryLocation::Root {
9622 self.place_container(inst).await?;
9623 self.state.push_log(format!("Placed {label}"));
9624 return Ok(());
9625 }
9626 self.drop_item(inst, row.from).await?;
9627 self.state.push_log(format!("Dropped {label}"));
9628 Ok(())
9629 }
9630
9631 pub async fn drop_item(
9632 &mut self,
9633 item_instance_id: uuid::Uuid,
9634 from: flatland_protocol::InventoryLocation,
9635 ) -> anyhow::Result<()> {
9636 self.seq += 1;
9637 self.session
9638 .submit_intent(Intent::DropItem {
9639 entity_id: self.state.entity_id,
9640 item_instance_id,
9641 from,
9642 seq: self.seq,
9643 })
9644 .await?;
9645 self.state.intents_sent += 1;
9646 Ok(())
9647 }
9648
9649 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
9651 let Some(row) = self.state.inventory_selected_row() else {
9652 anyhow::bail!("inventory empty");
9653 };
9654 if row.is_equip_shell {
9655 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
9656 }
9657 if row.is_chest_shell {
9658 anyhow::bail!("can't destroy a placed chest from the inventory list");
9659 }
9660 let Some(instance_id) = row.stack.item_instance_id else {
9661 anyhow::bail!("item has no instance id");
9662 };
9663 if self
9664 .state
9665 .hand_equipped_instance_ids()
9666 .contains(&instance_id)
9667 {
9668 anyhow::bail!("unequip that item first");
9669 }
9670 if self.state.deed_bound(&row.stack) {
9671 anyhow::bail!(
9672 "cannot destroy a property deed — store it or trade it to another player"
9673 );
9674 }
9675 if self.state.key_drop_blocked(&row.stack) {
9676 anyhow::bail!("cannot destroy the key while its chest is locked");
9677 }
9678 let item_label = row
9679 .stack
9680 .display_name
9681 .clone()
9682 .unwrap_or_else(|| row.stack.template_id.clone());
9683 self.state.destroy_picker = Some(DestroyPicker {
9684 item_instance_id: instance_id,
9685 from: row.from,
9686 item_label,
9687 stack_quantity: row.stack.quantity,
9688 quantity: row.stack.quantity,
9689 });
9690 self.state.destroy_confirm_pending = false;
9691 self.state.show_destroy_picker = true;
9692 self.state.show_move_picker = false;
9693 self.state.move_picker = None;
9694 Ok(())
9695 }
9696
9697 pub fn close_destroy_picker(&mut self) {
9698 self.state.show_destroy_picker = false;
9699 self.state.destroy_confirm_pending = false;
9700 self.state.destroy_picker = None;
9701 }
9702
9703 pub fn cancel_destroy_confirm(&mut self) {
9704 self.state.destroy_confirm_pending = false;
9705 }
9706
9707 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
9708 if self.state.destroy_picker.is_none() {
9709 self.close_destroy_picker();
9710 return Ok(());
9711 }
9712 self.state.destroy_confirm_pending = true;
9713 Ok(())
9714 }
9715
9716 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
9717 let Some(picker) = self.state.destroy_picker.clone() else {
9718 self.close_destroy_picker();
9719 return Ok(());
9720 };
9721 let qty = if picker.quantity >= picker.stack_quantity {
9722 None
9723 } else {
9724 Some(picker.quantity)
9725 };
9726 self.destroy_item(picker.item_instance_id, picker.from, qty)
9727 .await?;
9728 let destroyed = qty.unwrap_or(picker.stack_quantity);
9729 if destroyed >= picker.stack_quantity {
9730 self.state
9731 .push_log(format!("Destroyed {}", picker.item_label));
9732 } else {
9733 self.state.push_log(format!(
9734 "Destroyed {} ×{} of {}",
9735 picker.item_label, destroyed, picker.stack_quantity
9736 ));
9737 }
9738 self.close_destroy_picker();
9739 Ok(())
9740 }
9741
9742 pub async fn destroy_item(
9743 &mut self,
9744 item_instance_id: uuid::Uuid,
9745 from: flatland_protocol::InventoryLocation,
9746 quantity: Option<u32>,
9747 ) -> anyhow::Result<()> {
9748 self.seq += 1;
9749 self.session
9750 .submit_intent(Intent::DestroyItem {
9751 entity_id: self.state.entity_id,
9752 item_instance_id,
9753 from,
9754 quantity,
9755 seq: self.seq,
9756 })
9757 .await?;
9758 self.state.intents_sent += 1;
9759 Ok(())
9760 }
9761
9762 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
9764 if let Some(row) = self.state.inventory_selected_row() {
9765 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
9766 return self.toggle_placed_chest_lock(container_id).await;
9767 }
9768 }
9769 self.toggle_nearby_chest_lock().await
9770 }
9771
9772 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
9773 let chest = self
9774 .state
9775 .placed_containers
9776 .iter()
9777 .find(|c| c.id == container_id)
9778 .cloned()
9779 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9780 let (px, py) = self.state.player_position();
9781 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9782 anyhow::bail!("too far from {}", chest.display_name);
9783 }
9784 if !chest.accessible && chest.locked {
9785 anyhow::bail!(
9786 "need the matching key for {} (each crafted chest has its own key)",
9787 chest.display_name
9788 );
9789 }
9790 let lock = !chest.locked;
9791 self.set_container_locked(
9792 flatland_protocol::InventoryLocation::Placed {
9793 container_id: chest.id.clone(),
9794 },
9795 lock,
9796 )
9797 .await?;
9798 self.state.push_log(if lock {
9799 format!("Locked {}", chest.display_name)
9800 } else {
9801 format!("Unlocked {}", chest.display_name)
9802 });
9803 Ok(())
9804 }
9805
9806 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
9808 let chest = self
9809 .state
9810 .nearest_placed_container(CONTAINER_RANGE_M)
9811 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
9812 self.toggle_placed_chest_lock(&chest.id).await
9813 }
9814
9815 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
9816 self.equip_mainhand(None).await
9817 }
9818
9819 pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
9820 if !self.state.is_alive() {
9821 anyhow::bail!("you are dead");
9822 }
9823 self.seq += 1;
9824 self.session
9825 .submit_intent(Intent::EquipOffhand {
9826 entity_id: self.state.entity_id,
9827 template_id,
9828 instance_id: None,
9829 seq: self.seq,
9830 })
9831 .await?;
9832 self.state.intents_sent += 1;
9833 Ok(())
9834 }
9835
9836 pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
9837 self.equip_offhand(None).await
9838 }
9839
9840 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
9841 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
9842 for slot in slots {
9843 self.equip_worn(slot, None).await?;
9844 }
9845 Ok(())
9846 }
9847
9848 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
9849 let (px, py) = self.state.player_position();
9850 let nearest = self
9851 .state
9852 .placed_containers
9853 .iter()
9854 .min_by(|a, b| {
9855 let da = (a.x - px).hypot(a.y - py);
9856 let db = (b.x - px).hypot(b.y - py);
9857 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
9858 })
9859 .cloned();
9860 let Some(chest) = nearest else {
9861 anyhow::bail!("no chest nearby");
9862 };
9863 if (chest.x - px).hypot(chest.y - py) > 2.0 {
9864 anyhow::bail!("too far from chest");
9865 }
9866 self.pickup_container(chest.id).await
9867 }
9868
9869 pub async fn equip_worn(
9870 &mut self,
9871 slot: BodySlot,
9872 instance_id: Option<uuid::Uuid>,
9873 ) -> anyhow::Result<()> {
9874 self.seq += 1;
9875 self.session
9876 .submit_intent(Intent::EquipWorn {
9877 entity_id: self.state.entity_id,
9878 slot,
9879 instance_id,
9880 seq: self.seq,
9881 })
9882 .await?;
9883 self.state.intents_sent += 1;
9884 Ok(())
9885 }
9886
9887 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
9888 self.seq += 1;
9889 self.session
9890 .submit_intent(Intent::PlaceContainer {
9891 entity_id: self.state.entity_id,
9892 item_instance_id,
9893 seq: self.seq,
9894 })
9895 .await?;
9896 self.state.intents_sent += 1;
9897 Ok(())
9898 }
9899
9900 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
9901 self.seq += 1;
9902 self.session
9903 .submit_intent(Intent::PickupContainer {
9904 entity_id: self.state.entity_id,
9905 container_id,
9906 seq: self.seq,
9907 })
9908 .await?;
9909 self.state.intents_sent += 1;
9910 Ok(())
9911 }
9912
9913 pub async fn move_item(
9914 &mut self,
9915 item_instance_id: uuid::Uuid,
9916 from: flatland_protocol::InventoryLocation,
9917 to: flatland_protocol::InventoryLocation,
9918 to_parent_instance_id: Option<uuid::Uuid>,
9919 quantity: Option<u32>,
9920 ) -> anyhow::Result<()> {
9921 self.seq += 1;
9922 self.session
9923 .submit_intent(Intent::MoveItem {
9924 entity_id: self.state.entity_id,
9925 item_instance_id,
9926 from,
9927 to,
9928 to_parent_instance_id,
9929 quantity,
9930 seq: self.seq,
9931 })
9932 .await?;
9933 self.state.intents_sent += 1;
9934 Ok(())
9935 }
9936
9937 pub async fn set_container_locked(
9938 &mut self,
9939 location: flatland_protocol::InventoryLocation,
9940 locked: bool,
9941 ) -> anyhow::Result<()> {
9942 self.seq += 1;
9943 self.session
9944 .submit_intent(Intent::SetContainerLocked {
9945 entity_id: self.state.entity_id,
9946 location,
9947 locked,
9948 seq: self.seq,
9949 })
9950 .await?;
9951 self.state.intents_sent += 1;
9952 Ok(())
9953 }
9954
9955 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
9956 if !self.state.is_alive() {
9957 anyhow::bail!("you are dead");
9958 }
9959 self.seq += 1;
9960 self.session
9961 .submit_intent(Intent::Use {
9962 entity_id: self.state.entity_id,
9963 template_id: template_id.to_string(),
9964 seq: self.seq,
9965 })
9966 .await?;
9967 self.state.intents_sent += 1;
9968 Ok(())
9969 }
9970
9971 pub async fn use_grant(
9973 &mut self,
9974 grant_instance_id: uuid::Uuid,
9975 target_instance_id: uuid::Uuid,
9976 ) -> 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::UseGrant {
9983 entity_id: self.state.entity_id,
9984 grant_instance_id,
9985 target_instance_id,
9986 seq: self.seq,
9987 })
9988 .await?;
9989 self.state.intents_sent += 1;
9990 Ok(())
9991 }
9992
9993 pub fn open_craft_menu(&mut self) {
9994 self.state.show_craft_menu = true;
9995 self.state.show_shop_menu = false;
9996 self.state.shop_catalog = None;
9997 self.state.show_stats = false;
9998 self.state.show_inventory_menu = false;
9999 self.state.reload_craft_prefs();
10000 self.state.craft_tab = CraftTab::Ready;
10001 self.state.craft_filter.clear();
10002 self.state.craft_filter_focused = false;
10003 self.state.craft_menu_index = 0;
10004 self.state.clamp_craft_menu_index();
10005 self.state.craft_batch_quantity = 1;
10006 self.state.clamp_craft_batch_quantity();
10007 }
10008
10009 pub fn close_craft_menu(&mut self) {
10010 self.state.show_craft_menu = false;
10011 self.state.craft_filter_focused = false;
10012 }
10013
10014 pub fn toggle_keychain_menu(&mut self) {
10015 if self.state.show_keychain_menu {
10016 self.close_keychain_menu();
10017 } else {
10018 self.state.show_keychain_menu = true;
10019 self.state.show_craft_menu = false;
10020 self.state.show_shop_menu = false;
10021 self.state.show_inventory_menu = false;
10022 let n = self.state.keychain_entries().len();
10023 if n == 0 {
10024 self.state.keychain_menu_index = 0;
10025 } else {
10026 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
10027 }
10028 }
10029 }
10030
10031 pub fn close_keychain_menu(&mut self) {
10032 self.state.show_keychain_menu = false;
10033 }
10034
10035 pub fn keychain_menu_move(&mut self, delta: i32) {
10036 let n = self.state.keychain_entries().len();
10037 if n == 0 {
10038 self.state.keychain_menu_index = 0;
10039 return;
10040 }
10041 let idx = self.state.keychain_menu_index as i32 + delta;
10042 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
10043 }
10044
10045 pub fn keychain_menu_page(&mut self, pages: i32) {
10046 let n = self.state.keychain_entries().len();
10047 self.state.keychain_menu_index = page_list_index(self.state.keychain_menu_index, pages, n);
10048 }
10049
10050 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
10051 if !self.state.is_alive() {
10052 anyhow::bail!("you are dead");
10053 }
10054 let entries = self.state.keychain_entries();
10055 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
10056 anyhow::bail!("nothing selected");
10057 };
10058 let Some(instance_id) = entry.stack.item_instance_id else {
10059 anyhow::bail!("key has no instance id");
10060 };
10061 if entry.stowed {
10062 self.move_item(
10063 instance_id,
10064 flatland_protocol::InventoryLocation::Keychain,
10065 flatland_protocol::InventoryLocation::Root,
10066 None,
10067 Some(1),
10068 )
10069 .await
10070 } else {
10071 self.move_item(
10072 instance_id,
10073 flatland_protocol::InventoryLocation::Root,
10074 flatland_protocol::InventoryLocation::Keychain,
10075 None,
10076 Some(1),
10077 )
10078 .await
10079 }
10080 }
10081
10082 pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
10083 let npc_id = self.state.shop_catalog.as_ref().map(|c| c.npc_id.clone());
10084 self.state.show_shop_menu = false;
10085 self.state.shop_catalog = None;
10086 self.state.clear_shop_trade_log();
10087 if let Some(npc_id) = npc_id {
10088 self.seq += 1;
10089 self.session
10090 .submit_intent(Intent::ShopClose {
10091 entity_id: self.state.entity_id,
10092 npc_id,
10093 seq: self.seq,
10094 })
10095 .await?;
10096 self.state.intents_sent += 1;
10097 }
10098 Ok(())
10099 }
10100
10101 pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10102 let Some(panel) = self.state.bank_panel.clone() else {
10103 return Ok(());
10104 };
10105 self.seq += 1;
10106 self.session
10107 .submit_intent(Intent::BankDeposit {
10108 entity_id: self.state.entity_id,
10109 npc_id: panel.npc_id,
10110 amount_copper,
10111 seq: self.seq,
10112 })
10113 .await?;
10114 self.state.intents_sent += 1;
10115 Ok(())
10116 }
10117
10118 pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10119 let Some(panel) = self.state.bank_panel.clone() else {
10120 return Ok(());
10121 };
10122 self.seq += 1;
10123 self.session
10124 .submit_intent(Intent::BankWithdraw {
10125 entity_id: self.state.entity_id,
10126 npc_id: panel.npc_id,
10127 amount_copper,
10128 seq: self.seq,
10129 })
10130 .await?;
10131 self.state.intents_sent += 1;
10132 Ok(())
10133 }
10134
10135 pub async fn bank_transfer(
10136 &mut self,
10137 to_character_id: Option<uuid::Uuid>,
10138 to_name: String,
10139 amount_copper: u64,
10140 ) -> anyhow::Result<()> {
10141 let Some(panel) = self.state.bank_panel.clone() else {
10142 return Ok(());
10143 };
10144 self.seq += 1;
10145 self.session
10146 .submit_intent(Intent::BankTransfer {
10147 entity_id: self.state.entity_id,
10148 npc_id: panel.npc_id,
10149 to_character_id,
10150 to_name,
10151 amount_copper,
10152 seq: self.seq,
10153 })
10154 .await?;
10155 self.state.intents_sent += 1;
10156 Ok(())
10157 }
10158
10159 pub fn bank_menu_move(&mut self, delta: i32) {
10160 let n = self.state.bank_menu_options().len();
10161 if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
10162 return;
10163 }
10164 let idx = self.state.bank_menu_index as i32;
10165 self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10166 }
10167
10168 pub fn storage_menu_move(&mut self, delta: i32) {
10169 let n = self.state.storage_menu_options().len();
10170 if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
10171 return;
10172 }
10173 let idx = self.state.storage_menu_index as i32;
10174 self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10175 }
10176
10177 pub fn storage_pick_move(&mut self, delta: i32) {
10178 let n = match &self.state.storage_ui_mode {
10179 StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
10180 StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
10181 self.state.storage_vault_options().len()
10182 }
10183 StorageUiMode::Menu
10184 | StorageUiMode::StoreAmount { .. }
10185 | StorageUiMode::TakeAmount { .. }
10186 | StorageUiMode::ShipAmount { .. } => 0,
10187 };
10188 if n == 0 {
10189 return;
10190 }
10191 match &mut self.state.storage_ui_mode {
10192 StorageUiMode::StorePick { index }
10193 | StorageUiMode::TakePick { index }
10194 | StorageUiMode::ShipPick { index, .. } => {
10195 *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10196 }
10197 StorageUiMode::Menu
10198 | StorageUiMode::StoreAmount { .. }
10199 | StorageUiMode::TakeAmount { .. }
10200 | StorageUiMode::ShipAmount { .. } => {}
10201 }
10202 }
10203
10204 pub fn storage_ui_back(&mut self) {
10205 self.state.storage_ui_mode = match &self.state.storage_ui_mode {
10206 StorageUiMode::StoreAmount { pick_index, .. } => {
10207 StorageUiMode::StorePick { index: *pick_index }
10208 }
10209 StorageUiMode::TakeAmount { pick_index, .. } => {
10210 StorageUiMode::TakePick { index: *pick_index }
10211 }
10212 StorageUiMode::ShipAmount {
10213 dest_building_id,
10214 dest_label,
10215 pick_index,
10216 ..
10217 } => StorageUiMode::ShipPick {
10218 dest_building_id: dest_building_id.clone(),
10219 dest_label: dest_label.clone(),
10220 index: *pick_index,
10221 },
10222 StorageUiMode::StorePick { .. }
10223 | StorageUiMode::TakePick { .. }
10224 | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
10225 StorageUiMode::Menu => StorageUiMode::Menu,
10226 };
10227 }
10228
10229 pub fn storage_amount_append_char(&mut self, c: char) {
10230 match &mut self.state.storage_ui_mode {
10231 StorageUiMode::StoreAmount { input, .. }
10232 | StorageUiMode::TakeAmount { input, .. }
10233 | StorageUiMode::ShipAmount { input, .. } => {
10234 if c.is_ascii_digit() && input.len() < 8 {
10235 input.push(c);
10236 }
10237 }
10238 _ => {}
10239 }
10240 }
10241
10242 pub fn storage_amount_backspace(&mut self) {
10243 match &mut self.state.storage_ui_mode {
10244 StorageUiMode::StoreAmount { input, .. }
10245 | StorageUiMode::TakeAmount { input, .. }
10246 | StorageUiMode::ShipAmount { input, .. } => {
10247 input.pop();
10248 }
10249 _ => {}
10250 }
10251 }
10252
10253 pub fn storage_ui_typing(&self) -> bool {
10254 matches!(
10255 self.state.storage_ui_mode,
10256 StorageUiMode::StoreAmount { .. }
10257 | StorageUiMode::TakeAmount { .. }
10258 | StorageUiMode::ShipAmount { .. }
10259 )
10260 }
10261
10262 pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
10263 match self.state.storage_ui_mode.clone() {
10264 StorageUiMode::Menu => {
10265 let index = self.state.storage_menu_index;
10266 match index {
10267 0 => {
10268 let opts = self.state.storage_store_options();
10269 if opts.is_empty() {
10270 self.state.push_log("Nothing loose to store.");
10271 return Ok(());
10272 }
10273 self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
10274 }
10275 1 => {
10276 let opts = self.state.storage_vault_options();
10277 if opts.is_empty() {
10278 self.state.push_log("Vault is empty.");
10279 return Ok(());
10280 }
10281 self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
10282 }
10283 n => {
10284 let dest = self
10285 .state
10286 .storage_panel
10287 .as_ref()
10288 .and_then(|p| p.ship_destinations.get(n - 2))
10289 .cloned();
10290 let Some(dest) = dest else {
10291 return Ok(());
10292 };
10293 let opts = self.state.storage_vault_options();
10294 if opts.is_empty() {
10295 self.state.push_log("Vault is empty — nothing to ship.");
10296 return Ok(());
10297 }
10298 self.state.storage_ui_mode = StorageUiMode::ShipPick {
10299 dest_building_id: dest.building_id,
10300 dest_label: dest.label,
10301 index: 0,
10302 };
10303 }
10304 }
10305 }
10306 StorageUiMode::StorePick { index } => {
10307 let opts = self.state.storage_store_options();
10308 let Some(opt) = opts.get(index) else {
10309 self.state.push_log("Nothing loose to store.");
10310 self.state.storage_ui_mode = StorageUiMode::Menu;
10311 return Ok(());
10312 };
10313 self.state.storage_ui_mode = StorageUiMode::StoreAmount {
10314 pick_index: index,
10315 item_instance_id: opt.item_instance_id,
10316 label: opt.label.clone(),
10317 max_qty: opt.quantity.max(1),
10318 input: String::new(),
10319 };
10320 }
10321 StorageUiMode::TakePick { index } => {
10322 let opts = self.state.storage_vault_options();
10323 let Some(opt) = opts.get(index) else {
10324 self.state.push_log("Vault is empty.");
10325 self.state.storage_ui_mode = StorageUiMode::Menu;
10326 return Ok(());
10327 };
10328 self.state.storage_ui_mode = StorageUiMode::TakeAmount {
10329 pick_index: index,
10330 item_instance_id: opt.item_instance_id,
10331 label: opt.label.clone(),
10332 max_qty: opt.quantity.max(1),
10333 input: String::new(),
10334 };
10335 }
10336 StorageUiMode::ShipPick {
10337 dest_building_id,
10338 dest_label,
10339 index,
10340 } => {
10341 let opts = self.state.storage_vault_options();
10342 let Some(opt) = opts.get(index) else {
10343 self.state.push_log("Vault is empty — nothing to ship.");
10344 self.state.storage_ui_mode = StorageUiMode::Menu;
10345 return Ok(());
10346 };
10347 self.state.storage_ui_mode = StorageUiMode::ShipAmount {
10348 dest_building_id,
10349 dest_label,
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::StoreAmount {
10358 item_instance_id,
10359 max_qty,
10360 input,
10361 ..
10362 } => {
10363 let Some(qty) = parse_storage_quantity(&input) else {
10364 self.state.push_log("Enter a quantity (blank or 0 = all).");
10365 return Ok(());
10366 };
10367 let qty = qty.map(|n| n.min(max_qty).max(1));
10368 self.storage_store(item_instance_id, qty).await?;
10369 self.state.storage_ui_mode = StorageUiMode::Menu;
10370 }
10371 StorageUiMode::TakeAmount {
10372 item_instance_id,
10373 max_qty,
10374 input,
10375 ..
10376 } => {
10377 let Some(qty) = parse_storage_quantity(&input) else {
10378 self.state.push_log("Enter a quantity (blank or 0 = all).");
10379 return Ok(());
10380 };
10381 let qty = qty.map(|n| n.min(max_qty).max(1));
10382 self.storage_take(item_instance_id, qty).await?;
10383 self.state.storage_ui_mode = StorageUiMode::Menu;
10384 }
10385 StorageUiMode::ShipAmount {
10386 dest_building_id,
10387 item_instance_id,
10388 max_qty,
10389 input,
10390 ..
10391 } => {
10392 let Some(qty) = parse_storage_quantity(&input) else {
10393 self.state.push_log("Enter a quantity (blank or 0 = all).");
10394 return Ok(());
10395 };
10396 let qty = qty.map(|n| n.min(max_qty).max(1));
10397 self.storage_ship(dest_building_id, item_instance_id, qty)
10398 .await?;
10399 self.state.storage_ui_mode = StorageUiMode::Menu;
10400 }
10401 }
10402 Ok(())
10403 }
10404
10405 pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
10406 match self.state.bank_ui_mode.clone() {
10407 BankUiMode::Menu => {
10408 let choice = self
10409 .state
10410 .bank_menu_options()
10411 .get(self.state.bank_menu_index)
10412 .copied()
10413 .unwrap_or("Deposit…");
10414 match choice {
10415 "Withdraw…" => {
10416 self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
10417 input: String::new(),
10418 };
10419 }
10420 "Deposit all" => self.bank_deposit(0).await?,
10421 "Withdraw all" => self.bank_withdraw(0).await?,
10422 "Transfer…" => {
10423 self.state.bank_ui_mode = BankUiMode::TransferName {
10424 input: String::new(),
10425 };
10426 }
10427 _ => {
10428 self.state.bank_ui_mode = BankUiMode::DepositAmount {
10429 input: String::new(),
10430 };
10431 }
10432 }
10433 }
10434 BankUiMode::DepositAmount { input } => {
10435 let Some(amount) = parse_bank_copper_amount(&input) else {
10436 self.state
10437 .push_log("Enter a copper amount (blank or 0 = everything on person).");
10438 return Ok(());
10439 };
10440 self.bank_deposit(amount).await?;
10441 self.state.bank_ui_mode = BankUiMode::Menu;
10442 }
10443 BankUiMode::WithdrawAmount { input } => {
10444 let Some(amount) = parse_bank_copper_amount(&input) else {
10445 self.state
10446 .push_log("Enter a copper amount (blank or 0 = full ledger).");
10447 return Ok(());
10448 };
10449 self.bank_withdraw(amount).await?;
10450 self.state.bank_ui_mode = BankUiMode::Menu;
10451 }
10452 BankUiMode::TransferName { input } => {
10453 let name = input.trim().to_string();
10454 if name.is_empty() {
10455 self.state.push_log("Enter the recipient character name.");
10456 return Ok(());
10457 }
10458 self.state.bank_ui_mode = BankUiMode::TransferAmount {
10459 to_name: name,
10460 input: String::new(),
10461 };
10462 }
10463 BankUiMode::TransferAmount { to_name, input } => {
10464 let amount: u64 = match input.trim().parse() {
10465 Ok(v) if v > 0 => v,
10466 _ => {
10467 self.state
10468 .push_log("Enter a positive copper amount to transfer.");
10469 return Ok(());
10470 }
10471 };
10472 self.bank_transfer(None, to_name, amount).await?;
10473 self.state.bank_ui_mode = BankUiMode::Menu;
10474 }
10475 }
10476 Ok(())
10477 }
10478
10479 pub fn bank_transfer_back(&mut self) {
10480 match &self.state.bank_ui_mode {
10481 BankUiMode::TransferAmount { to_name, .. } => {
10482 self.state.bank_ui_mode = BankUiMode::TransferName {
10483 input: to_name.clone(),
10484 };
10485 }
10486 BankUiMode::TransferName { .. }
10487 | BankUiMode::DepositAmount { .. }
10488 | BankUiMode::WithdrawAmount { .. } => {
10489 self.state.bank_ui_mode = BankUiMode::Menu;
10490 }
10491 BankUiMode::Menu => {}
10492 }
10493 }
10494
10495 pub fn bank_transfer_append_char(&mut self, c: char) {
10496 match &mut self.state.bank_ui_mode {
10497 BankUiMode::TransferName { input } => {
10498 if input.len() < 32 && !c.is_control() {
10499 input.push(c);
10500 }
10501 }
10502 BankUiMode::DepositAmount { input }
10503 | BankUiMode::WithdrawAmount { input }
10504 | BankUiMode::TransferAmount { input, .. } => {
10505 if c.is_ascii_digit() && input.len() < 12 {
10506 input.push(c);
10507 }
10508 }
10509 BankUiMode::Menu => {}
10510 }
10511 }
10512
10513 pub fn bank_transfer_backspace(&mut self) {
10514 match &mut self.state.bank_ui_mode {
10515 BankUiMode::TransferName { input }
10516 | BankUiMode::DepositAmount { input }
10517 | BankUiMode::WithdrawAmount { input }
10518 | BankUiMode::TransferAmount { input, .. } => {
10519 input.pop();
10520 }
10521 BankUiMode::Menu => {}
10522 }
10523 }
10524
10525 pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
10526 let npc_id = self.state.bank_panel.as_ref().map(|p| p.npc_id.clone());
10527 self.state.clear_bank_panel();
10528 if let Some(npc_id) = npc_id {
10529 self.seq += 1;
10530 self.session
10531 .submit_intent(Intent::BankClose {
10532 entity_id: self.state.entity_id,
10533 npc_id,
10534 seq: self.seq,
10535 })
10536 .await?;
10537 self.state.intents_sent += 1;
10538 }
10539 Ok(())
10540 }
10541
10542 pub async fn storage_store(
10543 &mut self,
10544 item_instance_id: uuid::Uuid,
10545 quantity: Option<u32>,
10546 ) -> anyhow::Result<()> {
10547 let Some(panel) = self.state.storage_panel.clone() else {
10548 return Ok(());
10549 };
10550 self.seq += 1;
10551 self.session
10552 .submit_intent(Intent::StorageStore {
10553 entity_id: self.state.entity_id,
10554 npc_id: panel.npc_id,
10555 item_instance_id,
10556 quantity,
10557 seq: self.seq,
10558 })
10559 .await?;
10560 self.state.intents_sent += 1;
10561 Ok(())
10562 }
10563
10564 pub async fn storage_take(
10565 &mut self,
10566 item_instance_id: uuid::Uuid,
10567 quantity: Option<u32>,
10568 ) -> anyhow::Result<()> {
10569 let Some(panel) = self.state.storage_panel.clone() else {
10570 return Ok(());
10571 };
10572 self.seq += 1;
10573 self.session
10574 .submit_intent(Intent::StorageTake {
10575 entity_id: self.state.entity_id,
10576 npc_id: panel.npc_id,
10577 item_instance_id,
10578 quantity,
10579 seq: self.seq,
10580 })
10581 .await?;
10582 self.state.intents_sent += 1;
10583 Ok(())
10584 }
10585
10586 pub async fn storage_ship(
10587 &mut self,
10588 dest_building_id: String,
10589 item_instance_id: uuid::Uuid,
10590 quantity: Option<u32>,
10591 ) -> anyhow::Result<()> {
10592 let Some(panel) = self.state.storage_panel.clone() else {
10593 return Ok(());
10594 };
10595 self.seq += 1;
10596 self.session
10597 .submit_intent(Intent::StorageShip {
10598 entity_id: self.state.entity_id,
10599 npc_id: panel.npc_id,
10600 dest_building_id,
10601 item_instance_id,
10602 quantity,
10603 seq: self.seq,
10604 })
10605 .await?;
10606 self.state.intents_sent += 1;
10607 Ok(())
10608 }
10609
10610 pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
10611 let npc_id = self.state.storage_panel.as_ref().map(|p| p.npc_id.clone());
10612 self.state.clear_storage_panel();
10613 if let Some(npc_id) = npc_id {
10614 self.seq += 1;
10615 self.session
10616 .submit_intent(Intent::StorageClose {
10617 entity_id: self.state.entity_id,
10618 npc_id,
10619 seq: self.seq,
10620 })
10621 .await?;
10622 self.state.intents_sent += 1;
10623 }
10624 Ok(())
10625 }
10626
10627 pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
10628 let npc_id = self.state.market_panel.as_ref().map(|p| p.npc_id.clone());
10629 self.state.clear_market_panel();
10630 if let Some(npc_id) = npc_id {
10631 self.seq += 1;
10632 self.session
10633 .submit_intent(Intent::MarketClose {
10634 entity_id: self.state.entity_id,
10635 npc_id,
10636 seq: self.seq,
10637 })
10638 .await?;
10639 self.state.intents_sent += 1;
10640 }
10641 Ok(())
10642 }
10643
10644 pub fn market_move_selection(&mut self, delta: i32) {
10645 let indices = self.state.market_filtered_listing_indices();
10646 let n = indices.len();
10647 if n == 0 {
10648 self.state.market_menu_index = 0;
10649 return;
10650 }
10651 let cur = self.state.market_menu_index as i32;
10652 self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
10653 }
10654
10655 pub fn market_page_selection(&mut self, pages: i32) {
10656 let indices = self.state.market_filtered_listing_indices();
10657 let n = indices.len();
10658 if n == 0 {
10659 self.state.market_menu_index = 0;
10660 return;
10661 }
10662 self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
10663 }
10664
10665 pub fn market_list_page(&mut self, pages: i32) {
10666 match &self.state.market_ui_mode {
10667 MarketUiMode::ListSource { index } => {
10668 let n = self.state.market_list_source_options().len();
10669 if n == 0 {
10670 return;
10671 }
10672 let next = page_list_index(*index, pages, n);
10673 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
10674 }
10675 MarketUiMode::ListPricingMode { index, .. } => {
10676 let next = page_list_index(*index, pages, 2);
10677 if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
10678 {
10679 *index = next;
10680 }
10681 }
10682 MarketUiMode::ListPick { source, index } => {
10683 let opts = self.state.market_list_item_options(source);
10684 let n = opts.len();
10685 if n == 0 {
10686 return;
10687 }
10688 let next = page_list_index(*index, pages, n);
10689 self.state.market_ui_mode = MarketUiMode::ListPick {
10690 source: source.clone(),
10691 index: next,
10692 };
10693 }
10694 _ => {}
10695 }
10696 }
10697
10698 pub fn market_cycle_category(&mut self, delta: i32) {
10699 let groups = self.state.market_available_category_groups();
10700 let mut labels: Vec<Option<&'static str>> = vec![None];
10702 labels.extend(groups.into_iter().map(Some));
10703 let n = labels.len() as i32;
10704 let cur = labels
10705 .iter()
10706 .position(|g| *g == self.state.market_category_filter)
10707 .unwrap_or(0) as i32;
10708 let next = (cur + delta).rem_euclid(n) as usize;
10709 self.state.market_category_filter = labels[next];
10710 self.state.market_menu_index = 0;
10711 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10712 let source = source.clone();
10713 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10714 }
10715 }
10716
10717 pub fn focus_market_filter(&mut self) {
10718 self.state.market_filter_focused = true;
10719 }
10720
10721 pub fn append_market_filter_char(&mut self, ch: char) {
10722 if !self.state.market_filter_focused {
10723 return;
10724 }
10725 if !is_list_filter_char(ch) {
10726 return;
10727 }
10728 if self.state.market_filter.len() < 48 {
10729 self.state.market_filter.push(ch);
10730 self.state.market_menu_index = 0;
10731 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10732 let source = source.clone();
10733 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10734 }
10735 }
10736 }
10737
10738 pub fn market_filter_backspace(&mut self) {
10739 if !self.state.market_filter_focused {
10740 return;
10741 }
10742 self.state.market_filter.pop();
10743 self.state.market_menu_index = 0;
10744 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10745 let source = source.clone();
10746 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10747 }
10748 }
10749
10750 pub fn clear_or_blur_market_filter(&mut self) -> bool {
10752 if self.state.market_filter_focused {
10753 if !self.state.market_filter.is_empty() {
10754 self.state.market_filter.clear();
10755 self.state.market_menu_index = 0;
10756 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10757 let source = source.clone();
10758 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10759 }
10760 return true;
10761 }
10762 self.state.market_filter_focused = false;
10763 return true;
10764 }
10765 if !self.state.market_filter.is_empty() {
10766 self.state.market_filter.clear();
10767 self.state.market_menu_index = 0;
10768 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10769 let source = source.clone();
10770 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10771 }
10772 return true;
10773 }
10774 false
10775 }
10776
10777 pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
10778 if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
10779 return self.market_confirm_buy(listing_id, qty).await;
10780 }
10781 let Some(panel) = self.state.market_panel.clone() else {
10782 return Ok(());
10783 };
10784 let indices = self.state.market_filtered_listing_indices();
10785 let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
10786 return Ok(());
10787 };
10788 let Some(listing) = panel.listings.get(raw_idx) else {
10789 return Ok(());
10790 };
10791 if listing.mine {
10792 self.seq += 1;
10793 self.session
10794 .submit_intent(Intent::MarketDelist {
10795 entity_id: self.state.entity_id,
10796 npc_id: panel.npc_id.clone(),
10797 listing_id: listing.listing_id,
10798 dest: flatland_protocol::GoodsLocation::Person,
10799 seq: self.seq,
10800 })
10801 .await?;
10802 self.state.intents_sent += 1;
10803 return Ok(());
10804 }
10805 if listing.npc_price {
10806 self.state
10807 .push_log("NPC-price listings are bought by merchants only.");
10808 return Ok(());
10809 }
10810 let qty = 1u32.min(listing.quantity).max(1);
10811 let line = listing.unit_price_copper.saturating_mul(qty as u64);
10812 self.state.market_buy_confirm = Some((
10813 listing.listing_id,
10814 qty,
10815 listing.unit_price_copper,
10816 line,
10817 listing.display_name.clone(),
10818 ));
10819 Ok(())
10820 }
10821
10822 pub async fn market_confirm_buy(
10823 &mut self,
10824 listing_id: uuid::Uuid,
10825 quantity: u32,
10826 ) -> anyhow::Result<()> {
10827 let Some(panel) = self.state.market_panel.clone() else {
10828 self.state.market_buy_confirm = None;
10829 return Ok(());
10830 };
10831 self.state.market_buy_confirm = None;
10832 self.seq += 1;
10833 self.session
10834 .submit_intent(Intent::MarketBuy {
10835 entity_id: self.state.entity_id,
10836 npc_id: panel.npc_id,
10837 listing_id,
10838 quantity,
10839 dest: flatland_protocol::GoodsLocation::Person,
10840 seq: self.seq,
10841 })
10842 .await?;
10843 self.state.intents_sent += 1;
10844 Ok(())
10845 }
10846
10847 pub fn market_begin_list(&mut self) {
10849 if self.state.market_panel.is_none() {
10850 return;
10851 }
10852 let sources = self.state.market_list_source_options();
10853 if sources.is_empty() {
10854 self.state.push_log("Nothing to list from.");
10855 return;
10856 }
10857 if sources.len() == 1 {
10859 let (source, _) = sources[0].clone();
10860 let opts = self.state.market_list_item_options(&source);
10861 if opts.is_empty() {
10862 self.state.push_log("Nothing loose to list.");
10863 return;
10864 }
10865 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10866 self.state.market_buy_confirm = None;
10867 return;
10868 }
10869 self.state.market_buy_confirm = None;
10870 self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
10871 }
10872
10873 pub fn market_ui_back(&mut self) {
10874 self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
10875 MarketUiMode::Browse => MarketUiMode::Browse,
10876 MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
10877 MarketUiMode::ListPick { .. } => {
10878 if self.state.market_list_source_options().len() <= 1 {
10879 MarketUiMode::Browse
10880 } else {
10881 MarketUiMode::ListSource { index: 0 }
10882 }
10883 }
10884 MarketUiMode::ListAmount {
10885 source, pick_index, ..
10886 } => MarketUiMode::ListPick {
10887 source,
10888 index: pick_index,
10889 },
10890 MarketUiMode::ListPricingMode {
10891 source,
10892 item_instance_id,
10893 template_id,
10894 label,
10895 max_qty,
10896 quantity,
10897 pick_index,
10898 ..
10899 } => {
10900 let input = quantity.map(|q| q.to_string()).unwrap_or_default();
10901 MarketUiMode::ListAmount {
10902 source,
10903 pick_index,
10904 item_instance_id,
10905 template_id,
10906 label,
10907 max_qty,
10908 input,
10909 }
10910 }
10911 MarketUiMode::ListPrice {
10912 source,
10913 pick_index,
10914 item_instance_id,
10915 template_id,
10916 label,
10917 max_qty,
10918 quantity,
10919 ..
10920 } => MarketUiMode::ListPricingMode {
10921 source,
10922 pick_index,
10923 item_instance_id,
10924 template_id,
10925 label,
10926 quantity,
10927 max_qty,
10928 index: 1,
10929 },
10930 };
10931 }
10932
10933 pub fn market_list_move(&mut self, delta: i32) {
10934 match &self.state.market_ui_mode {
10935 MarketUiMode::ListSource { index } => {
10936 let n = self.state.market_list_source_options().len();
10937 if n == 0 {
10938 return;
10939 }
10940 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10941 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
10942 }
10943 MarketUiMode::ListPricingMode { index, .. } => {
10944 let next = (*index as i32 + delta).rem_euclid(2) as usize;
10945 if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
10946 {
10947 *index = next;
10948 }
10949 }
10950 MarketUiMode::ListPick { source, index } => {
10951 let opts = self.state.market_list_item_options(source);
10952 let n = opts.len();
10953 if n == 0 {
10954 return;
10955 }
10956 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10957 self.state.market_ui_mode = MarketUiMode::ListPick {
10958 source: source.clone(),
10959 index: next,
10960 };
10961 }
10962 _ => {}
10963 }
10964 }
10965
10966 pub fn market_list_amount_append_char(&mut self, c: char) {
10967 if !c.is_ascii_digit() {
10968 return;
10969 }
10970 match &mut self.state.market_ui_mode {
10971 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
10972 if input.len() < 12 {
10973 input.push(c);
10974 }
10975 }
10976 _ => {}
10977 }
10978 }
10979
10980 pub fn market_list_amount_backspace(&mut self) {
10981 match &mut self.state.market_ui_mode {
10982 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
10983 input.pop();
10984 }
10985 _ => {}
10986 }
10987 }
10988
10989 pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
10990 match self.state.market_ui_mode.clone() {
10991 MarketUiMode::Browse => Ok(()),
10992 MarketUiMode::ListSource { index } => {
10993 let sources = self.state.market_list_source_options();
10994 let Some((source, _)) = sources.get(index).cloned() else {
10995 return Ok(());
10996 };
10997 let opts = self.state.market_list_item_options(&source);
10998 if opts.is_empty() {
10999 self.state.push_log("Nothing to list from that source.");
11000 return Ok(());
11001 }
11002 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11003 Ok(())
11004 }
11005 MarketUiMode::ListPick { source, index } => {
11006 let opts = self.state.market_list_item_options(&source);
11007 let Some(opt) = opts.get(index) else {
11008 self.state.push_log("Nothing to list.");
11009 self.state.market_ui_mode = MarketUiMode::Browse;
11010 return Ok(());
11011 };
11012 self.state.market_ui_mode = MarketUiMode::ListAmount {
11013 source,
11014 pick_index: index,
11015 item_instance_id: opt.item_instance_id,
11016 template_id: opt.template_id.clone(),
11017 label: opt.label.clone(),
11018 max_qty: opt.quantity.max(1),
11019 input: String::new(),
11020 };
11021 Ok(())
11022 }
11023 MarketUiMode::ListAmount {
11024 source,
11025 pick_index,
11026 item_instance_id,
11027 template_id,
11028 label,
11029 max_qty,
11030 input,
11031 ..
11032 } => {
11033 let Some(qty_opt) = parse_storage_quantity(&input) else {
11034 self.state.push_log("Enter a quantity (blank = all).");
11035 return Ok(());
11036 };
11037 if let Some(q) = qty_opt {
11038 if q > max_qty {
11039 self.state.push_log(format!("Only {max_qty} available."));
11040 return Ok(());
11041 }
11042 }
11043 self.state.market_ui_mode = MarketUiMode::ListPricingMode {
11044 source,
11045 pick_index,
11046 item_instance_id,
11047 template_id,
11048 label,
11049 quantity: qty_opt,
11050 max_qty,
11051 index: 0,
11052 };
11053 Ok(())
11054 }
11055 MarketUiMode::ListPricingMode {
11056 source,
11057 pick_index,
11058 item_instance_id,
11059 template_id,
11060 label,
11061 quantity,
11062 max_qty,
11063 index,
11064 } => {
11065 if index == 0 {
11066 if self
11067 .state
11068 .npc_market_dump_unit_estimate(&template_id)
11069 .is_none()
11070 {
11071 self.state
11072 .push_log("That item has no NPC value — use a fixed price instead.");
11073 return Ok(());
11074 }
11075 return self
11076 .submit_market_list_intent(
11077 source,
11078 item_instance_id,
11079 quantity,
11080 0,
11081 true,
11082 &label,
11083 )
11084 .await;
11085 }
11086 self.state.market_ui_mode = MarketUiMode::ListPrice {
11087 source,
11088 pick_index,
11089 item_instance_id,
11090 template_id,
11091 label,
11092 quantity,
11093 max_qty,
11094 input: String::new(),
11095 };
11096 Ok(())
11097 }
11098 MarketUiMode::ListPrice {
11099 source,
11100 item_instance_id,
11101 label,
11102 quantity,
11103 input,
11104 ..
11105 } => {
11106 let price = input.trim().parse::<u64>().unwrap_or(0);
11107 if price == 0 {
11108 self.state
11109 .push_log("Enter a unit price of at least 1 copper.");
11110 return Ok(());
11111 }
11112 self.submit_market_list_intent(
11113 source,
11114 item_instance_id,
11115 quantity,
11116 price,
11117 false,
11118 &label,
11119 )
11120 .await
11121 }
11122 }
11123 }
11124
11125 async fn submit_market_list_intent(
11126 &mut self,
11127 source: MarketListSourceKind,
11128 item_instance_id: uuid::Uuid,
11129 quantity: Option<u32>,
11130 unit_price_copper: u64,
11131 npc_price: bool,
11132 label: &str,
11133 ) -> anyhow::Result<()> {
11134 let Some(panel) = self.state.market_panel.clone() else {
11135 self.state.market_ui_mode = MarketUiMode::Browse;
11136 return Ok(());
11137 };
11138 let goods = match source {
11139 MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
11140 MarketListSourceKind::TownStorage { building_id } => {
11141 flatland_protocol::GoodsLocation::TownStorage { building_id }
11142 }
11143 };
11144 self.seq += 1;
11145 self.session
11146 .submit_intent(Intent::MarketList {
11147 entity_id: self.state.entity_id,
11148 npc_id: panel.npc_id,
11149 source: goods,
11150 item_instance_id,
11151 quantity,
11152 unit_price_copper,
11153 npc_price,
11154 seq: self.seq,
11155 })
11156 .await?;
11157 self.state.intents_sent += 1;
11158 if npc_price {
11159 self.state
11160 .push_log(format!("Listing {label} at NPC price…"));
11161 } else {
11162 self.state
11163 .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
11164 }
11165 self.state.market_ui_mode = MarketUiMode::Browse;
11166 Ok(())
11167 }
11168
11169 pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
11171 let return_to_verbs = self.state.npc_verb_target.is_some();
11172 self.close_shop_menu().await?;
11173 if return_to_verbs {
11174 self.state.show_npc_verb_menu = true;
11175 self.state.npc_verb_notice = None;
11176 }
11177 Ok(())
11178 }
11179
11180 pub fn shop_tab_toggle(&mut self) {
11181 self.state.shop_tab = match self.state.shop_tab {
11182 ShopTab::Buy => ShopTab::Sell,
11183 ShopTab::Sell => ShopTab::Buy,
11184 };
11185 self.state.shop_menu_index = 0;
11186 if self.state.shop_tab == ShopTab::Sell {
11187 self.state.shop_quantity_set_max();
11188 }
11189 self.state.clamp_shop_selection();
11190 }
11191
11192 pub fn shop_menu_move(&mut self, delta: i32) {
11193 self.state.shop_menu_move(delta);
11194 }
11195
11196 pub fn shop_quantity_adjust(&mut self, delta: i32) {
11197 self.state.shop_quantity_adjust(delta);
11198 }
11199
11200 pub fn shop_quantity_set_max(&mut self) {
11201 self.state.shop_quantity_set_max();
11202 }
11203
11204 pub fn shop_quantity_set_min(&mut self) {
11205 self.state.shop_quantity_set_min();
11206 }
11207
11208 pub fn toggle_quest_menu(&mut self) {
11209 self.state.show_quest_menu = !self.state.show_quest_menu;
11210 if self.state.show_quest_menu {
11211 self.state.quest_menu_index = 0;
11212 self.state.quest_withdraw_confirm = false;
11213 self.state.show_workers_menu = false;
11214 }
11215 }
11216
11217 pub fn toggle_workers_menu(&mut self) {
11218 if self.state.show_workers_menu {
11219 self.close_workers_menu_ui();
11220 } else {
11221 self.state.show_workers_menu = true;
11222 if self.state.workers_menu_index >= self.state.hired_workers.len() {
11224 self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11225 }
11226 self.state.show_quest_menu = false;
11227 self.close_worker_give_picker();
11228 self.close_worker_give_target_picker();
11229 self.close_worker_take_picker();
11230 self.close_worker_teach_picker();
11231 self.cancel_worker_rename();
11232 }
11233 }
11234
11235 pub fn close_workers_menu_ui(&mut self) {
11237 self.state.show_workers_menu = false;
11238 self.cancel_worker_dismissal();
11239 self.close_worker_give_picker();
11240 self.close_worker_give_target_picker();
11241 self.close_worker_take_picker();
11242 self.close_worker_teach_picker();
11243 self.cancel_worker_rename();
11244 }
11245
11246 pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
11248 let Some(idx) = self
11249 .state
11250 .hired_workers
11251 .iter()
11252 .position(|w| w.instance_id == instance_id)
11253 else {
11254 anyhow::bail!("worker not found");
11255 };
11256 let label = self.state.hired_workers[idx].label.clone();
11257 self.state.show_workers_menu = true;
11258 self.state.workers_menu_index = idx;
11259 self.state.show_quest_menu = false;
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 self.set_worker_attending(instance_id, true).await?;
11266 self.state
11267 .push_log(format!("Managing {label} — job paused while menu is open"));
11268 Ok(())
11269 }
11270
11271 pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
11273 self.close_workers_menu_ui();
11274 self.release_worker_attend().await
11275 }
11276
11277 async fn set_worker_attending(
11278 &mut self,
11279 instance_id: &str,
11280 attending: bool,
11281 ) -> anyhow::Result<()> {
11282 if attending {
11283 if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11284 return Ok(());
11285 }
11286 if let Some(prev) = self.state.attending_worker_instance_id.clone() {
11288 if prev != instance_id {
11289 self.send_attend_hired_worker(&prev, false).await?;
11290 }
11291 }
11292 self.send_attend_hired_worker(instance_id, true).await?;
11293 self.state.attending_worker_instance_id = Some(instance_id.to_string());
11294 } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11295 self.send_attend_hired_worker(instance_id, false).await?;
11296 self.state.attending_worker_instance_id = None;
11297 }
11298 Ok(())
11299 }
11300
11301 pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
11302 let Some(id) = self.state.attending_worker_instance_id.take() else {
11303 return Ok(());
11304 };
11305 self.send_attend_hired_worker(&id, false).await
11306 }
11307
11308 async fn send_attend_hired_worker(
11309 &mut self,
11310 worker_instance_id: &str,
11311 attending: bool,
11312 ) -> anyhow::Result<()> {
11313 self.seq += 1;
11314 self.session
11315 .submit_intent(Intent::AttendHiredWorker {
11316 entity_id: self.state.entity_id,
11317 worker_instance_id: worker_instance_id.to_string(),
11318 attending,
11319 seq: self.seq,
11320 })
11321 .await?;
11322 self.state.intents_sent += 1;
11323 Ok(())
11324 }
11325
11326 pub fn workers_menu_move(&mut self, delta: i32) {
11327 let n = self.state.hired_workers.len();
11328 if n == 0 {
11329 return;
11330 }
11331 let idx = self.state.workers_menu_index as i32;
11332 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
11333 }
11334
11335 pub fn toggle_workers_menu_compact(&mut self) {
11336 self.state.workers_menu_compact = !self.state.workers_menu_compact;
11337 let mut cfg = crate::client_config::ClientConfig::load();
11338 let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
11339 }
11340
11341 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
11342 let Some(worker) = self
11343 .state
11344 .hired_workers
11345 .get(self.state.workers_menu_index)
11346 .cloned()
11347 else {
11348 anyhow::bail!("no worker selected");
11349 };
11350 self.dismiss_worker_by_id(&worker.instance_id, &worker.label)
11351 .await
11352 }
11353
11354 pub fn request_worker_dismissal(&mut self) -> anyhow::Result<()> {
11356 let Some(worker) = self
11357 .state
11358 .hired_workers
11359 .get(self.state.workers_menu_index)
11360 .cloned()
11361 else {
11362 anyhow::bail!("no worker selected");
11363 };
11364 self.state.worker_dismiss_confirmation = Some(WorkerDismissConfirmation {
11365 worker_instance_id: worker.instance_id,
11366 worker_label: worker.label,
11367 });
11368 Ok(())
11369 }
11370
11371 pub fn cancel_worker_dismissal(&mut self) {
11372 self.state.worker_dismiss_confirmation = None;
11373 }
11374
11375 pub async fn confirm_worker_dismissal(&mut self) -> anyhow::Result<()> {
11376 let Some(confirm) = self.state.worker_dismiss_confirmation.clone() else {
11377 return Ok(());
11378 };
11379 self.dismiss_worker_by_id(&confirm.worker_instance_id, &confirm.worker_label)
11380 .await?;
11381 self.cancel_worker_dismissal();
11382 Ok(())
11383 }
11384
11385 async fn dismiss_worker_by_id(
11386 &mut self,
11387 worker_instance_id: &str,
11388 worker_label: &str,
11389 ) -> anyhow::Result<()> {
11390 self.seq += 1;
11391 self.session
11392 .submit_intent(Intent::DismissWorker {
11393 entity_id: self.state.entity_id,
11394 worker_instance_id: worker_instance_id.to_string(),
11395 seq: self.seq,
11396 })
11397 .await?;
11398 self.state.intents_sent += 1;
11399 self.state
11400 .hired_workers
11401 .retain(|w| w.instance_id != worker_instance_id);
11402 if self.state.workers_menu_index >= self.state.hired_workers.len() {
11403 self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11404 }
11405 self.state.push_log(format!("Dismissed {worker_label}"));
11406 Ok(())
11407 }
11408
11409 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
11410 let Some(worker) = self
11411 .state
11412 .hired_workers
11413 .get(self.state.workers_menu_index)
11414 .cloned()
11415 else {
11416 anyhow::bail!("no worker selected");
11417 };
11418 let mode = match worker.mode {
11419 flatland_protocol::WorkerModeView::Companion => "defender",
11420 flatland_protocol::WorkerModeView::Defender => "job_loop",
11421 flatland_protocol::WorkerModeView::JobLoop => "idle",
11422 flatland_protocol::WorkerModeView::Idle => "companion",
11423 };
11424 self.seq += 1;
11425 self.session
11426 .submit_intent(Intent::SetWorkerMode {
11427 entity_id: self.state.entity_id,
11428 worker_instance_id: worker.instance_id,
11429 mode: mode.into(),
11430 seq: self.seq,
11431 })
11432 .await?;
11433 self.state.intents_sent += 1;
11434 Ok(())
11435 }
11436
11437 pub async fn workers_deliver_selected_to_storage(&mut self) -> anyhow::Result<()> {
11438 let Some(worker) = self
11439 .state
11440 .hired_workers
11441 .get(self.state.workers_menu_index)
11442 .cloned()
11443 else {
11444 anyhow::bail!("no worker selected");
11445 };
11446 if !matches!(worker.mode, flatland_protocol::WorkerModeView::Companion) {
11447 anyhow::bail!("switch the worker to companion mode first");
11448 }
11449 if worker.step_label.starts_with("delivering to ")
11450 || worker.step_label == "returning to you"
11451 {
11452 anyhow::bail!("worker is already delivering to storage");
11453 }
11454 self.seq += 1;
11455 self.session
11456 .submit_intent(Intent::DeliverWorkerToNearestStorage {
11457 entity_id: self.state.entity_id,
11458 worker_instance_id: worker.instance_id.clone(),
11459 seq: self.seq,
11460 })
11461 .await?;
11462 self.state.intents_sent += 1;
11463 self.state.push_log(format!(
11464 "{} is delivering carried items to storage",
11465 worker.label
11466 ));
11467 Ok(())
11468 }
11469
11470 pub async fn workers_cancel_delivery_selected(&mut self) -> anyhow::Result<()> {
11471 let Some(worker) = self
11472 .state
11473 .hired_workers
11474 .get(self.state.workers_menu_index)
11475 .cloned()
11476 else {
11477 anyhow::bail!("no worker selected");
11478 };
11479 if !(worker.step_label.starts_with("delivering to ")
11480 || worker.step_label == "returning to you")
11481 {
11482 anyhow::bail!("worker has no active delivery");
11483 }
11484 self.seq += 1;
11485 self.session
11486 .submit_intent(Intent::CancelWorkerDelivery {
11487 entity_id: self.state.entity_id,
11488 worker_instance_id: worker.instance_id,
11489 seq: self.seq,
11490 })
11491 .await?;
11492 self.state.intents_sent += 1;
11493 self.state
11494 .push_log(format!("Canceled delivery for {}", worker.label));
11495 Ok(())
11496 }
11497
11498 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
11499 if self.state.hired_workers.is_empty() {
11500 return self.hire_worker_laborer().await;
11501 }
11502 self.workers_toggle_mode_selected().await
11503 }
11504
11505 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11508 let row = self
11509 .state
11510 .inventory_selected_row()
11511 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
11512 .clone();
11513 if row.from != flatland_protocol::InventoryLocation::Root {
11514 anyhow::bail!("select a carried item to give");
11515 }
11516 let Some(instance_id) = row.stack.item_instance_id else {
11517 anyhow::bail!("that stack can't be given");
11518 };
11519 let options = self.nearby_worker_give_targets();
11520 if options.is_empty() {
11521 anyhow::bail!(
11522 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
11523 );
11524 }
11525 let item_label = row
11526 .stack
11527 .display_name
11528 .as_deref()
11529 .unwrap_or(&row.stack.template_id)
11530 .to_string();
11531 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
11532 item_instance_id: instance_id,
11533 item_label,
11534 quantity: None,
11535 options,
11536 });
11537 self.state.worker_give_target_picker_index = 0;
11538 self.state.show_worker_give_target_picker = true;
11539 self.state.show_inventory_menu = false;
11541 Ok(())
11542 }
11543
11544 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
11546 let (px, py, _) = self.state.player_position_with_z();
11547 let mut options: Vec<WorkerGiveTargetOption> = self
11548 .state
11549 .hired_workers
11550 .iter()
11551 .filter_map(|w| {
11552 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
11553 if dist > WORKER_GIVE_RANGE_M {
11554 return None;
11555 }
11556 Some(WorkerGiveTargetOption {
11557 instance_id: w.instance_id.clone(),
11558 label: w.label.clone(),
11559 distance_m: dist,
11560 })
11561 })
11562 .collect();
11563 options.sort_by(|a, b| {
11564 a.distance_m
11565 .partial_cmp(&b.distance_m)
11566 .unwrap_or(std::cmp::Ordering::Equal)
11567 });
11568 options
11569 }
11570
11571 pub fn close_worker_give_target_picker(&mut self) {
11572 self.state.show_worker_give_target_picker = false;
11573 self.state.worker_give_target_picker = None;
11574 self.state.worker_give_target_picker_index = 0;
11575 }
11576
11577 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
11578 let Some(picker) = &self.state.worker_give_target_picker else {
11579 return;
11580 };
11581 let n = picker.options.len();
11582 if n == 0 {
11583 return;
11584 }
11585 let idx = self.state.worker_give_target_picker_index as i32;
11586 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11587 }
11588
11589 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11590 let Some(picker) = self.state.worker_give_target_picker.clone() else {
11591 anyhow::bail!("give target picker not open");
11592 };
11593 let Some(opt) = picker
11594 .options
11595 .get(self.state.worker_give_target_picker_index)
11596 .cloned()
11597 else {
11598 anyhow::bail!("no worker selected");
11599 };
11600 let Some(worker) = self
11601 .state
11602 .hired_workers
11603 .iter()
11604 .find(|w| w.instance_id == opt.instance_id)
11605 .cloned()
11606 else {
11607 self.close_worker_give_target_picker();
11608 anyhow::bail!("worker no longer hired");
11609 };
11610 self.give_item_to_worker(
11611 &worker.instance_id,
11612 &worker.label,
11613 worker.x,
11614 worker.y,
11615 picker.item_instance_id,
11616 &picker.item_label,
11617 picker.quantity,
11618 )
11619 .await?;
11620 self.close_worker_give_target_picker();
11621 Ok(())
11622 }
11623
11624 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
11626 self.open_worker_give_target_picker()
11627 }
11628
11629 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
11631 let Some(worker) = self
11632 .state
11633 .hired_workers
11634 .get(self.state.workers_menu_index)
11635 .cloned()
11636 else {
11637 anyhow::bail!("select a hired worker first");
11638 };
11639 let (px, py, _) = self.state.player_position_with_z();
11640 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11641 if dist > WORKER_GIVE_RANGE_M {
11642 anyhow::bail!(
11643 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
11644 worker.label
11645 );
11646 }
11647 let options = self.state.giveable_inventory_options();
11648 if options.is_empty() {
11649 anyhow::bail!("nothing in inventory to give");
11650 }
11651 self.state.worker_give_picker = Some(WorkerGivePicker {
11652 worker_instance_id: worker.instance_id,
11653 worker_label: worker.label,
11654 options,
11655 });
11656 self.state.worker_give_picker_index = 0;
11657 self.state.show_worker_give_picker = true;
11658 Ok(())
11659 }
11660
11661 pub fn close_worker_give_picker(&mut self) {
11662 self.state.show_worker_give_picker = false;
11663 self.state.worker_give_picker = None;
11664 self.state.worker_give_picker_index = 0;
11665 }
11666
11667 pub fn worker_give_picker_move(&mut self, delta: i32) {
11668 let Some(picker) = &self.state.worker_give_picker else {
11669 return;
11670 };
11671 let n = picker.options.len();
11672 if n == 0 {
11673 return;
11674 }
11675 let idx = self.state.worker_give_picker_index as i32;
11676 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11677 }
11678
11679 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
11681 let Some(picker) = self.state.worker_give_picker.clone() else {
11682 anyhow::bail!("give picker not open");
11683 };
11684 let Some(opt) = picker
11685 .options
11686 .get(self.state.worker_give_picker_index)
11687 .cloned()
11688 else {
11689 anyhow::bail!("no item selected");
11690 };
11691 let Some(worker) = self
11692 .state
11693 .hired_workers
11694 .iter()
11695 .find(|w| w.instance_id == picker.worker_instance_id)
11696 .cloned()
11697 else {
11698 self.close_worker_give_picker();
11699 anyhow::bail!("worker no longer hired");
11700 };
11701 self.give_item_to_worker(
11702 &worker.instance_id,
11703 &worker.label,
11704 worker.x,
11705 worker.y,
11706 opt.item_instance_id,
11707 &opt.label,
11708 None,
11709 )
11710 .await?;
11711 let options = self.state.giveable_inventory_options();
11713 if options.is_empty() {
11714 self.close_worker_give_picker();
11715 } else {
11716 self.state.worker_give_picker = Some(WorkerGivePicker {
11717 worker_instance_id: picker.worker_instance_id,
11718 worker_label: picker.worker_label,
11719 options,
11720 });
11721 if self.state.worker_give_picker_index
11722 >= self
11723 .state
11724 .worker_give_picker
11725 .as_ref()
11726 .map(|p| p.options.len())
11727 .unwrap_or(0)
11728 {
11729 self.state.worker_give_picker_index = self
11730 .state
11731 .worker_give_picker
11732 .as_ref()
11733 .map(|p| p.options.len().saturating_sub(1))
11734 .unwrap_or(0);
11735 }
11736 }
11737 Ok(())
11738 }
11739
11740 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11742 let Some(worker) = self
11743 .state
11744 .hired_workers
11745 .get(self.state.workers_menu_index)
11746 .cloned()
11747 else {
11748 anyhow::bail!("select a hired worker first");
11749 };
11750 let (px, py, _) = self.state.player_position_with_z();
11751 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11752 if dist > WORKER_GIVE_RANGE_M {
11753 anyhow::bail!(
11754 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
11755 worker.label
11756 );
11757 }
11758 let options = self.state.teachable_blueprint_options(&worker);
11759 if options.is_empty() {
11760 anyhow::bail!("no recipes you know that {} still needs", worker.label);
11761 }
11762 self.state.worker_teach_picker = Some(WorkerTeachPicker {
11763 worker_instance_id: worker.instance_id,
11764 worker_label: worker.label,
11765 worker_level: worker.level,
11766 options,
11767 });
11768 self.state.worker_teach_picker_index = 0;
11769 self.state.show_worker_teach_picker = true;
11770 Ok(())
11771 }
11772
11773 pub fn close_worker_teach_picker(&mut self) {
11774 self.state.show_worker_teach_picker = false;
11775 self.state.worker_teach_picker = None;
11776 self.state.worker_teach_picker_index = 0;
11777 }
11778
11779 pub fn worker_teach_picker_move(&mut self, delta: i32) {
11780 let Some(picker) = &self.state.worker_teach_picker else {
11781 return;
11782 };
11783 let n = picker.options.len();
11784 if n == 0 {
11785 return;
11786 }
11787 let idx = self.state.worker_teach_picker_index as i32;
11788 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11789 }
11790
11791 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11792 let Some(picker) = self.state.worker_teach_picker.clone() else {
11793 anyhow::bail!("teach picker not open");
11794 };
11795 let Some(opt) = picker
11796 .options
11797 .get(self.state.worker_teach_picker_index)
11798 .cloned()
11799 else {
11800 anyhow::bail!("nothing selected");
11801 };
11802 if !opt.level_ok {
11803 anyhow::bail!(
11804 "{} needs level {} (is level {})",
11805 picker.worker_label,
11806 opt.min_level,
11807 opt.worker_level
11808 );
11809 }
11810 if !opt.can_afford {
11811 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
11812 }
11813 let Some(worker) = self
11814 .state
11815 .hired_workers
11816 .iter()
11817 .find(|w| w.instance_id == picker.worker_instance_id)
11818 .cloned()
11819 else {
11820 anyhow::bail!("worker gone");
11821 };
11822 let (px, py, _) = self.state.player_position_with_z();
11823 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11824 if dist > WORKER_GIVE_RANGE_M {
11825 anyhow::bail!("worker {} too far — stand next to them", worker.label);
11826 }
11827 self.seq += 1;
11828 self.session
11829 .submit_intent(Intent::TeachWorkerBlueprint {
11830 entity_id: self.state.entity_id,
11831 worker_instance_id: picker.worker_instance_id.clone(),
11832 blueprint_id: opt.blueprint_id.clone(),
11833 seq: self.seq,
11834 })
11835 .await?;
11836 self.state.intents_sent += 1;
11837 self.state.push_log(format!(
11838 "Teaching {} to {} ({} cp)",
11839 opt.label, picker.worker_label, opt.cost_copper
11840 ));
11841 self.close_worker_teach_picker();
11842 Ok(())
11843 }
11844
11845 async fn give_item_to_worker(
11846 &mut self,
11847 worker_instance_id: &str,
11848 worker_label: &str,
11849 worker_x: f32,
11850 worker_y: f32,
11851 item_instance_id: uuid::Uuid,
11852 item_label: &str,
11853 quantity: Option<u32>,
11854 ) -> anyhow::Result<()> {
11855 let (px, py, _) = self.state.player_position_with_z();
11856 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
11857 if dist > WORKER_GIVE_RANGE_M {
11858 anyhow::bail!("worker {worker_label} too far — stand next to them");
11859 }
11860 self.seq += 1;
11861 self.session
11862 .submit_intent(Intent::GiveWorkerItem {
11863 entity_id: self.state.entity_id,
11864 worker_instance_id: worker_instance_id.to_string(),
11865 item_instance_id,
11866 quantity,
11867 seq: self.seq,
11868 })
11869 .await?;
11870 self.state.intents_sent += 1;
11871 self.state
11872 .remove_carried_instance(item_instance_id, quantity);
11873 self.state
11874 .push_log(format!("Gave {item_label} to {worker_label}"));
11875 Ok(())
11876 }
11877
11878 pub async fn equip_item_on_worker(
11882 &mut self,
11883 worker_instance_id: &str,
11884 item_instance_id: uuid::Uuid,
11885 slot: &str,
11886 ) -> anyhow::Result<()> {
11887 let Some(worker) = self
11888 .state
11889 .hired_workers
11890 .iter()
11891 .find(|worker| worker.instance_id == worker_instance_id)
11892 .cloned()
11893 else {
11894 anyhow::bail!("worker not found");
11895 };
11896 let (px, py, _) = self.state.player_position_with_z();
11897 if (worker.x - px).hypot(worker.y - py) > WORKER_GIVE_RANGE_M {
11898 anyhow::bail!("worker {} too far — stand next to them", worker.label);
11899 }
11900 self.seq += 1;
11901 self.session
11902 .submit_intent(Intent::EquipWorkerItem {
11903 entity_id: self.state.entity_id,
11904 worker_instance_id: worker.instance_id.clone(),
11905 item_instance_id,
11906 slot: slot.to_string(),
11907 seq: self.seq,
11908 })
11909 .await?;
11910 self.state.intents_sent += 1;
11911 self.state
11912 .push_log(format!("Equipped {slot} on {}", worker.label));
11913 Ok(())
11914 }
11915
11916 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
11918 let Some(worker) = self
11919 .state
11920 .hired_workers
11921 .get(self.state.workers_menu_index)
11922 .cloned()
11923 else {
11924 anyhow::bail!("select a hired worker first");
11925 };
11926 let (px, py, _) = self.state.player_position_with_z();
11927 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11928 if dist > WORKER_GIVE_RANGE_M {
11929 anyhow::bail!(
11930 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
11931 worker.label
11932 );
11933 }
11934 let options = Self::worker_inventory_options(&worker);
11935 if options.is_empty() {
11936 anyhow::bail!("{} isn't carrying anything", worker.label);
11937 }
11938 let initial_qty = options
11939 .first()
11940 .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
11941 .unwrap_or(1);
11942 self.state.worker_take_picker = Some(WorkerTakePicker {
11943 worker_instance_id: worker.instance_id,
11944 worker_label: worker.label,
11945 options,
11946 quantity: initial_qty,
11947 });
11948 self.state.worker_take_picker_index = 0;
11949 self.state.show_worker_take_picker = true;
11950 Ok(())
11951 }
11952
11953 fn worker_inventory_options(
11954 worker: &flatland_protocol::HiredWorkerView,
11955 ) -> Vec<WorkerGiveOption> {
11956 worker
11957 .inventory
11958 .iter()
11959 .filter_map(|stack| {
11960 let item_instance_id = stack.item_instance_id?;
11961 let label = stack
11962 .display_name
11963 .clone()
11964 .unwrap_or_else(|| stack.template_id.clone());
11965 let label = if stack.quantity > 1 {
11966 format!("{label} ×{}", stack.quantity)
11967 } else {
11968 label
11969 };
11970 Some(WorkerGiveOption {
11971 item_instance_id,
11972 label,
11973 quantity: stack.quantity,
11974 template_id: stack.template_id.clone(),
11975 })
11976 })
11977 .collect()
11978 }
11979
11980 pub fn close_worker_take_picker(&mut self) {
11981 self.state.show_worker_take_picker = false;
11982 self.state.worker_take_picker = None;
11983 self.state.worker_take_picker_index = 0;
11984 }
11985
11986 pub fn worker_take_picker_move(&mut self, delta: i32) {
11987 let Some(picker) = &self.state.worker_take_picker else {
11988 return;
11989 };
11990 let n = picker.options.len();
11991 if n == 0 {
11992 return;
11993 }
11994 let idx = self.state.worker_take_picker_index as i32;
11995 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11996 self.clamp_worker_take_quantity();
11997 }
11998
11999 pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
12000 let Some(picker) = &mut self.state.worker_take_picker else {
12001 return;
12002 };
12003 let max = picker
12004 .options
12005 .get(self.state.worker_take_picker_index)
12006 .map(|o| o.quantity.max(1))
12007 .unwrap_or(1);
12008 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
12009 picker.quantity = next as u32;
12010 }
12011
12012 pub fn worker_take_picker_set_quantity_max(&mut self) {
12013 let Some(picker) = &mut self.state.worker_take_picker else {
12014 return;
12015 };
12016 let max = picker
12017 .options
12018 .get(self.state.worker_take_picker_index)
12019 .map(|o| o.quantity.max(1))
12020 .unwrap_or(1);
12021 picker.quantity = max;
12022 }
12023
12024 pub fn worker_take_picker_set_quantity_min(&mut self) {
12025 let Some(picker) = &mut self.state.worker_take_picker else {
12026 return;
12027 };
12028 picker.quantity = 1;
12029 self.clamp_worker_take_quantity();
12030 }
12031
12032 fn clamp_worker_take_quantity(&mut self) {
12033 let Some(picker) = &mut self.state.worker_take_picker else {
12034 return;
12035 };
12036 let max = picker
12037 .options
12038 .get(self.state.worker_take_picker_index)
12039 .map(|o| o.quantity.max(1))
12040 .unwrap_or(1);
12041 if picker.quantity == 0 || picker.quantity > max {
12042 picker.quantity = if max > 1 { 1 } else { max };
12043 }
12044 }
12045
12046 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
12047 let Some(picker) = self.state.worker_take_picker.clone() else {
12048 anyhow::bail!("take picker not open");
12049 };
12050 let Some(opt) = picker
12051 .options
12052 .get(self.state.worker_take_picker_index)
12053 .cloned()
12054 else {
12055 anyhow::bail!("no item selected");
12056 };
12057 let Some(worker) = self
12058 .state
12059 .hired_workers
12060 .iter()
12061 .find(|w| w.instance_id == picker.worker_instance_id)
12062 .cloned()
12063 else {
12064 self.close_worker_take_picker();
12065 anyhow::bail!("worker no longer hired");
12066 };
12067 let qty = picker.quantity.clamp(1, opt.quantity.max(1));
12068 let intent_qty = if qty >= opt.quantity { None } else { Some(qty) };
12069 self.take_item_from_worker(
12070 &worker.instance_id,
12071 &worker.label,
12072 worker.x,
12073 worker.y,
12074 opt.item_instance_id,
12075 &opt.label,
12076 intent_qty,
12077 )
12078 .await?;
12079 Ok(())
12082 }
12083
12084 async fn take_item_from_worker(
12085 &mut self,
12086 worker_instance_id: &str,
12087 worker_label: &str,
12088 worker_x: f32,
12089 worker_y: f32,
12090 item_instance_id: uuid::Uuid,
12091 item_label: &str,
12092 quantity: Option<u32>,
12093 ) -> anyhow::Result<()> {
12094 let (px, py, _) = self.state.player_position_with_z();
12095 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
12096 if dist > WORKER_GIVE_RANGE_M {
12097 anyhow::bail!("worker {worker_label} too far — stand next to them");
12098 }
12099 self.seq += 1;
12100 self.session
12101 .submit_intent(Intent::TakeWorkerItem {
12102 entity_id: self.state.entity_id,
12103 worker_instance_id: worker_instance_id.to_string(),
12104 item_instance_id,
12105 quantity,
12106 seq: self.seq,
12107 })
12108 .await?;
12109 self.state.intents_sent += 1;
12110 let qty_note = quantity.map(|q| format!(" ×{q}")).unwrap_or_default();
12111 self.state.push_log(format!(
12112 "Taking {item_label}{qty_note} from {worker_label}…"
12113 ));
12114 Ok(())
12115 }
12116
12117 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
12118 if let Some(since) = self.state.pending_worker_hire_since {
12119 if since.elapsed() < WORKER_HIRE_PENDING_TIMEOUT {
12120 anyhow::bail!("hire request still pending — wait for the worker roster update");
12121 }
12122 self.state.pending_worker_hire_since = None;
12123 }
12124 if !self.state.has_worker_lodging() {
12125 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
12126 }
12127 self.seq += 1;
12128 self.session
12129 .submit_intent(Intent::HireWorker {
12130 entity_id: self.state.entity_id,
12131 def_id: "worker_laborer".into(),
12132 wage_copper_per_interval: 8,
12133 lodging_container_id: None,
12134 job_yaml: None,
12135 seq: self.seq,
12136 })
12137 .await?;
12138 self.state.intents_sent += 1;
12139 self.state.pending_worker_hire_since = Some(Instant::now());
12140 Ok(())
12141 }
12142
12143 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
12144 let Some(worker) = self
12145 .state
12146 .hired_workers
12147 .get(self.state.workers_menu_index)
12148 .cloned()
12149 else {
12150 anyhow::bail!("select a hired worker first");
12151 };
12152 let lodging = worker.lodging_container_id.clone().or_else(|| {
12153 crate::worker_route_editor::owned_lodging_container_ids(
12154 &self.state.placed_containers,
12155 self.state.character_id,
12156 )
12157 .into_iter()
12158 .next()
12159 .map(|(id, _)| id)
12160 });
12161 let label = worker.label.clone();
12162 let editor = if let Some(route) = &worker.route {
12163 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
12164 worker.instance_id,
12165 worker.label,
12166 route,
12167 lodging,
12168 )
12169 } else {
12170 crate::worker_route_editor::WorkerRouteEditorState::new(
12171 worker.instance_id,
12172 worker.label,
12173 lodging,
12174 )
12175 };
12176 self.state.worker_route_editor = Some(editor);
12177 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12178 if let Some(collapsed) =
12179 crate::client_config::ClientConfig::load().worker_route_panel_collapsed
12180 {
12181 ed.panel_collapsed = collapsed;
12182 }
12183 }
12184 self.state.show_workers_menu = false;
12185 self.state.push_log(format!(
12186 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
12187 ));
12188 Ok(())
12189 }
12190
12191 pub fn close_worker_route_editor(&mut self) {
12192 self.state.worker_route_editor = None;
12193 }
12194
12195 pub fn worker_route_editor_toggle_panel(&mut self) {
12196 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12197 ed.toggle_panel_collapsed();
12198 let collapsed = ed.panel_collapsed;
12199 let mut cfg = crate::client_config::ClientConfig::load();
12200 let _ = cfg.save_worker_route_panel_collapsed(collapsed);
12201 }
12202 }
12203
12204 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
12205 let n = {
12206 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12207 return;
12208 };
12209 ed.append_waypoint(x, y, z);
12210 ed.stop_count()
12211 };
12212 self.state
12213 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
12214 }
12215
12216 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
12219 let (px, py, _) = self.state.player_position_with_z();
12220 let inside = self.state.effective_inside_building();
12221 crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
12222 &self.state.placed_containers,
12223 &self.state.buildings,
12224 self.state.character_id,
12225 px,
12226 py,
12227 &self.state.hired_workers,
12228 inside.as_deref(),
12229 )
12230 }
12231
12232 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
12233 self.state.route_editor_node_candidates()
12234 }
12235
12236 fn re_open_harvest_picker(&mut self, index: usize, picked: std::collections::BTreeSet<String>) {
12237 use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
12238 let nodes = self.state.route_editor_node_candidates();
12239 let index = if nodes.is_empty() {
12240 ROUTE_PICKER_DONE_ROW
12241 } else {
12242 index.max(1).min(nodes.len())
12243 };
12244 self.re_open_sheet(S::HarvestPicker {
12245 index,
12246 picked,
12247 nodes,
12248 });
12249 }
12250
12251 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
12252 let (px, py, _) = self.state.player_position_with_z();
12253 let templates = self.re_template_candidates();
12254 crate::worker_route_editor::trade_npc_candidates(
12255 &self.state.npcs,
12256 px,
12257 py,
12258 &templates,
12259 )
12260 }
12261
12262 fn re_template_candidates(&self) -> Vec<String> {
12263 let mut extra = Vec::new();
12264 if let Some(ed) = self.state.worker_route_editor.as_ref() {
12265 for stop in &ed.stops {
12266 match stop {
12267 crate::worker_route_editor::WorkerRouteStop::DepositAt {
12268 filter: Some(filter),
12269 ..
12270 } => extra.extend(filter.iter().cloned()),
12271 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. }
12272 | crate::worker_route_editor::WorkerRouteStop::ListOnMarket {
12273 template,
12274 ..
12275 } => {
12276 extra.push(template.clone());
12277 }
12278 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
12279 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint)
12280 {
12281 extra.push(bp.output.clone());
12282 for input in &bp.inputs {
12283 extra.push(input.template_id.clone());
12284 }
12285 }
12286 }
12287 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
12288 for it in items {
12289 extra.push(it.template.clone());
12290 }
12291 }
12292 _ => {}
12293 }
12294 }
12295 if let Some(worker) = self
12297 .state
12298 .hired_workers
12299 .iter()
12300 .find(|w| w.instance_id == ed.worker_instance_id)
12301 {
12302 for recipe in &worker.known_blueprint_ids {
12303 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
12304 extra.push(bp.output.clone());
12305 }
12306 }
12307 for stack in &worker.inventory {
12308 if !stack.template_id.is_empty() && stack.quantity > 0 {
12309 extra.push(stack.template_id.clone());
12310 }
12311 }
12312 }
12313 }
12314 crate::worker_route_editor::route_item_template_candidates(
12315 &self.state.placed_containers,
12316 self.state.character_id,
12317 &self.state.inventory,
12318 &self.state.blueprints,
12319 &self.state.resource_nodes,
12320 &extra,
12321 Some(&self.state.item_catalog),
12322 )
12323 }
12324
12325 fn re_blueprint_ids(&self) -> Vec<String> {
12326 let worker_known: Option<&[String]> = self
12327 .state
12328 .worker_route_editor
12329 .as_ref()
12330 .and_then(|ed| {
12331 self.state
12332 .hired_workers
12333 .iter()
12334 .find(|w| w.instance_id == ed.worker_instance_id)
12335 })
12336 .map(|w| w.known_blueprint_ids.as_slice());
12337 crate::worker_route_editor::worker_craft_blueprint_ids(&self.state.blueprints, worker_known)
12338 }
12339
12340 fn re_bed_candidates(&self) -> Vec<(String, String)> {
12341 crate::worker_route_editor::owned_lodging_container_ids(
12342 &self.state.placed_containers,
12343 self.state.character_id,
12344 )
12345 }
12346
12347 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
12348 self.state
12349 .placed_containers
12350 .iter()
12351 .find(|c| c.id == container_id)
12352 .map(|c| c.contents.clone())
12353 .unwrap_or_default()
12354 }
12355
12356 fn re_sheet_supports_filter(&self) -> bool {
12359 use crate::worker_route_editor::RouteEditorSheet as S;
12360 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12361 matches!(
12362 ed.sheet,
12363 S::HarvestPicker { .. }
12364 | S::SellItem { .. }
12365 | S::MarketListItem { .. }
12366 | S::DepositFilter { .. }
12367 | S::WithdrawItems { .. }
12368 | S::WithdrawContainers { .. }
12369 | S::DepositContainers { .. }
12370 | S::SellNpcs { .. }
12371 | S::CraftBlueprint { .. }
12372 | S::BedPicker { .. }
12373 )
12374 })
12375 }
12376
12377 pub fn re_sheet_row_visible(&self, row: usize) -> bool {
12379 use crate::worker_route_editor::{
12380 harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
12381 ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12382 };
12383 let Some(ed) = self.state.worker_route_editor.as_ref() else {
12384 return false;
12385 };
12386 let filter = &ed.sheet_filter;
12387 match &ed.sheet {
12388 S::HarvestPicker { nodes, .. } => harvest_picker_row_matches(nodes, row, filter),
12389 S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12390 if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
12391 return true;
12392 }
12393 let slot = row.saturating_sub(2);
12394 templates.get(slot).is_some_and(|t| {
12395 let label = self.state.template_display_name(t);
12396 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12397 })
12398 }
12399 S::DepositFilter { rows, .. } => {
12400 if row >= rows.len() {
12401 return true;
12402 }
12403 rows.get(row).is_some_and(|(t, _)| {
12404 let label = self.state.template_display_name(t);
12405 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12406 })
12407 }
12408 S::WithdrawItems { lines, .. } => {
12409 if row >= lines.len() {
12410 return true;
12411 }
12412 lines.get(row).is_some_and(|l| {
12413 let label = self.state.template_display_name(&l.template);
12414 list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
12415 })
12416 }
12417 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12418 self.re_container_candidates().get(row).is_some_and(|c| {
12419 list_filter_row_matches(
12420 filter,
12421 Some(c.dist),
12422 &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
12423 )
12424 })
12425 }
12426 S::SellNpcs { .. } => {
12427 if row == 0 {
12428 return true;
12429 }
12430 self.re_npc_candidates().get(row - 1).is_some_and(|n| {
12431 list_filter_row_matches(
12432 filter,
12433 Some(n.dist),
12434 &[n.label.as_str(), n.id.as_str()],
12435 )
12436 })
12437 }
12438 S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
12439 let label = self
12440 .state
12441 .blueprints
12442 .iter()
12443 .find(|b| &b.id == id)
12444 .map(|b| {
12445 if b.label.is_empty() {
12446 id.as_str()
12447 } else {
12448 b.label.as_str()
12449 }
12450 })
12451 .unwrap_or(id.as_str());
12452 list_filter_row_matches(filter, None, &[id.as_str(), label])
12453 }),
12454 S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
12455 list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
12456 }),
12457 _ => true,
12458 }
12459 }
12460
12461 fn re_sheet_clamp_index(&mut self) {
12462 let count = self.re_sheet_row_count();
12463 if count == 0 {
12464 return;
12465 }
12466 let cur = self.re_sheet_index();
12467 if self.re_sheet_row_visible(cur) {
12468 return;
12469 }
12470 for offset in 1..count {
12471 if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
12472 self.re_sheet_set_index(cur + offset);
12473 return;
12474 }
12475 if cur >= offset && self.re_sheet_row_visible(cur - offset) {
12476 self.re_sheet_set_index(cur - offset);
12477 return;
12478 }
12479 }
12480 }
12481
12482 fn re_sheet_set_index(&mut self, index: usize) {
12483 use crate::worker_route_editor::RouteEditorSheet as S;
12484 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12485 return;
12486 };
12487 match &mut ed.sheet {
12488 S::AddMenu { index: slot }
12489 | S::WaypointMenu { index: slot }
12490 | S::HarvestPicker { index: slot, .. }
12491 | S::WithdrawContainers { index: slot }
12492 | S::DepositContainers { index: slot }
12493 | S::SellNpcs { index: slot }
12494 | S::CraftBlueprint { index: slot }
12495 | S::BedPicker { index: slot }
12496 | S::FarmPlotPicker { index: slot, .. }
12497 | S::FarmPlantSeed { index: slot, .. }
12498 | S::WithdrawItems { index: slot, .. }
12499 | S::DepositFilter { index: slot, .. }
12500 | S::SellItem { index: slot, .. } | S::MarketListItem { index: slot, .. } => *slot = index,
12501 _ => {}
12502 }
12503 }
12504
12505 pub fn re_focus_sheet_filter(&mut self) {
12506 if !self.re_sheet_supports_filter() {
12507 return;
12508 }
12509 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12510 ed.sheet_filter_focused = true;
12511 }
12512 }
12513
12514 pub fn re_blur_sheet_filter_keep_text(&mut self) {
12515 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12516 return;
12517 };
12518 if !ed.sheet_filter_focused {
12519 return;
12520 }
12521 ed.sheet_filter_focused = false;
12522 self.re_sheet_clamp_index();
12523 }
12524
12525 pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
12526 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12527 return false;
12528 };
12529 if ed.sheet_filter_focused {
12530 ed.sheet_filter_focused = false;
12531 self.re_sheet_clamp_index();
12532 return true;
12533 }
12534 if !ed.sheet_filter.is_empty() {
12535 ed.sheet_filter.clear();
12536 self.re_sheet_clamp_index();
12537 return true;
12538 }
12539 false
12540 }
12541
12542 pub fn re_append_sheet_filter_char(&mut self, ch: char) {
12543 if ch.is_control() {
12544 return;
12545 }
12546 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12547 return;
12548 };
12549 if !ed.sheet_filter_focused {
12550 return;
12551 }
12552 ed.sheet_filter.push(ch);
12553 self.re_sheet_set_index(0);
12554 self.re_sheet_clamp_index();
12555 }
12556
12557 pub fn re_sheet_filter_backspace(&mut self) {
12558 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12559 return;
12560 };
12561 if !ed.sheet_filter_focused {
12562 return;
12563 }
12564 ed.sheet_filter.pop();
12565 self.re_sheet_set_index(0);
12566 self.re_sheet_clamp_index();
12567 }
12568
12569 pub fn re_sheet_row_count(&self) -> usize {
12571 use crate::worker_route_editor::{
12572 harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
12573 };
12574 let Some(ed) = self.state.worker_route_editor.as_ref() else {
12575 return 0;
12576 };
12577 match &ed.sheet {
12578 S::Stops => ed.stops.len(),
12579 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
12580 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
12581 S::WaypointMapPick => 0,
12582 S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
12583 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12584 self.re_container_candidates().len()
12585 }
12586 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, .. } => {
12590 sell_item_picker_row_count(templates.len())
12591 }
12592 S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
12593 S::WaitEntry { .. } => 1,
12594 S::BedPicker { .. } => self.re_bed_candidates().len(),
12595 S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
12596 S::FarmPlantSeed { seeds, .. } => seeds.len(),
12597 }
12598 }
12599
12600 pub fn re_sheet_index(&self) -> usize {
12602 use crate::worker_route_editor::RouteEditorSheet as S;
12603 let Some(ed) = self.state.worker_route_editor.as_ref() else {
12604 return 0;
12605 };
12606 match &ed.sheet {
12607 S::AddMenu { index }
12608 | S::WaypointMenu { index }
12609 | S::HarvestPicker { index, .. }
12610 | S::WithdrawContainers { index }
12611 | S::DepositContainers { index }
12612 | S::SellNpcs { index }
12613 | S::CraftBlueprint { index }
12614 | S::BedPicker { index }
12615 | S::FarmPlotPicker { index, .. }
12616 | S::FarmPlantSeed { index, .. }
12617 | S::WithdrawItems { index, .. }
12618 | S::DepositFilter { index, .. }
12619 | S::SellItem { index, .. } | S::MarketListItem { index, .. } => *index,
12620 _ => 0,
12621 }
12622 }
12623
12624 pub fn re_sheet_move(&mut self, delta: i32) {
12626 let count = self.re_sheet_row_count();
12627 if count == 0 {
12628 return;
12629 }
12630 let cur = self.re_sheet_index();
12631 let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
12632 self.re_sheet_set_index(next);
12633 }
12634
12635 pub fn re_sheet_page(&mut self, pages: i32) {
12636 let count = self.re_sheet_row_count();
12637 if count == 0 {
12638 return;
12639 }
12640 let cur = self.re_sheet_index();
12641 let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
12642 self.re_sheet_set_index(next);
12643 }
12644
12645 pub fn re_sheet_adjust(&mut self, delta: i32) {
12647 use crate::worker_route_editor::RouteEditorSheet as S;
12648 let index = self.re_sheet_index();
12649 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12650 return;
12651 };
12652 match &mut ed.sheet {
12653 S::WithdrawItems { lines, .. } => {
12654 if let Some(line) = lines.get_mut(index) {
12655 line.adjust_qty(delta);
12656 }
12657 }
12658 S::WaitEntry { ticks } => {
12659 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
12660 }
12661 _ => {}
12662 }
12663 }
12664
12665 pub fn re_sheet_back(&mut self) {
12666 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12667 return;
12668 };
12669 use crate::worker_route_editor::RouteEditorSheet as S;
12670 let was_editing = ed.editing_index.is_some();
12671 let from_top_picker = matches!(
12672 ed.sheet,
12673 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
12674 );
12675 ed.sheet_back();
12676 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
12677 self.state
12679 .push_log("Route: left edit sheet — press s to save current stops".to_string());
12680 }
12681 }
12682
12683 pub fn re_at_root_sheet(&self) -> bool {
12685 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12686 matches!(
12687 ed.sheet,
12688 crate::worker_route_editor::RouteEditorSheet::Stops
12689 )
12690 })
12691 }
12692
12693 pub fn re_open_add_menu(&mut self) {
12694 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12695 ed.open_add_menu();
12696 }
12697 }
12698
12699 pub fn re_open_bed_picker(&mut self) {
12700 let beds = self.re_bed_candidates();
12701 if beds.is_empty() {
12702 self.state
12703 .push_log("Route: place a camp bed first".to_string());
12704 return;
12705 }
12706 let current = self
12707 .state
12708 .worker_route_editor
12709 .as_ref()
12710 .and_then(|ed| ed.lodging_container_id.clone());
12711 let index = current
12712 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
12713 .unwrap_or(0);
12714 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
12715 }
12716
12717 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
12718 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12719 ed.open_sheet(sheet);
12720 }
12721 }
12722
12723 fn re_confirm_stop(&mut self, stop: crate::worker_route_editor::WorkerRouteStop, what: String) {
12725 let appended = self
12726 .state
12727 .worker_route_editor
12728 .as_mut()
12729 .is_some_and(|ed| ed.confirm_stop(stop));
12730 if appended {
12731 self.state.push_log(format!("Route: + {what}"));
12732 } else {
12733 self.state
12734 .push_log(format!("Route: {what} already in route — selected it"));
12735 }
12736 }
12737
12738 fn re_open_withdraw_items(&mut self, container_id: String) {
12739 use crate::worker_route_editor::{
12740 RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12741 };
12742 let contents = self.re_container_contents(&container_id);
12743 let existing = self
12747 .state
12748 .worker_route_editor
12749 .as_ref()
12750 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12751 .and_then(|stop| match stop {
12752 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
12753 _ => None,
12754 })
12755 .unwrap_or_default();
12756 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
12757 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12760 let _ = ed.retarget_withdraw_container(container_id.clone());
12761 }
12762 self.re_open_sheet(S::WithdrawItems {
12763 container_id,
12764 lines,
12765 index: 0,
12766 });
12767 }
12768
12769 fn re_withdraw_items_activate(&mut self, index: usize) {
12770 use crate::worker_route_editor::{
12771 RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12772 };
12773 enum Outcome {
12774 Cycled,
12775 Confirmed(String),
12776 Empty,
12777 }
12778 let outcome = {
12779 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12780 return;
12781 };
12782 let S::WithdrawItems {
12783 container_id,
12784 lines,
12785 index: sheet_index,
12786 } = &mut ed.sheet
12787 else {
12788 return;
12789 };
12790 *sheet_index = index;
12791 if index < lines.len() {
12792 lines[index].cycle();
12793 Outcome::Cycled
12794 } else {
12795 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
12796 if items.is_empty() {
12797 Outcome::Empty
12798 } else {
12799 let stop = WorkerRouteStop::WithdrawFrom {
12800 container_id: container_id.clone(),
12801 items,
12802 };
12803 let summary = stop.summary();
12804 ed.confirm_stop(stop);
12805 Outcome::Confirmed(summary)
12806 }
12807 }
12808 };
12809 match outcome {
12810 Outcome::Cycled => {}
12811 Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
12812 Outcome::Empty => self.state.push_log(
12813 "Route: pick at least one item (Space/Enter toggles All/qty)".to_string(),
12814 ),
12815 }
12816 }
12817
12818 fn re_open_deposit_filter(&mut self, container_id: String) {
12819 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12820 let existing_filter = self
12822 .state
12823 .worker_route_editor
12824 .as_ref()
12825 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12826 .and_then(|stop| match stop {
12827 WorkerRouteStop::DepositAt { filter, .. } => {
12828 Some(filter.clone().unwrap_or_default())
12829 }
12830 _ => None,
12831 });
12832 let mut candidates = self.re_template_candidates();
12833 if let Some(ref chosen) = existing_filter {
12834 for t in chosen {
12835 if !candidates.iter().any(|c| c == t) {
12836 candidates.push(t.clone());
12837 }
12838 }
12839 candidates.sort();
12840 candidates.dedup();
12841 }
12842 let rows: Vec<(String, bool)> = match existing_filter {
12843 Some(chosen) => candidates
12844 .iter()
12845 .map(|t| (t.clone(), chosen.contains(t)))
12846 .collect(),
12847 None => candidates.into_iter().map(|t| (t, false)).collect(),
12848 };
12849 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12850 let _ = ed.retarget_deposit_container(container_id.clone());
12851 }
12852 self.re_open_sheet(S::DepositFilter {
12853 container_id,
12854 rows,
12855 index: 0,
12856 });
12857 }
12858
12859 fn re_deposit_filter_activate(&mut self, index: usize) {
12860 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12861 let mut confirmed: Option<String> = None;
12862 {
12863 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12864 return;
12865 };
12866 let S::DepositFilter {
12867 container_id,
12868 rows,
12869 index: sheet_index,
12870 } = &mut ed.sheet
12871 else {
12872 return;
12873 };
12874 *sheet_index = index;
12875 if index < rows.len() {
12876 rows[index].1 = !rows[index].1;
12877 } else {
12878 let chosen: Vec<String> = rows
12880 .iter()
12881 .filter(|(_, on)| *on)
12882 .map(|(t, _)| t.clone())
12883 .collect();
12884 let filter = if chosen.is_empty() {
12885 None
12886 } else {
12887 Some(chosen)
12888 };
12889 let stop = WorkerRouteStop::DepositAt {
12890 container_id: container_id.clone(),
12891 filter,
12892 };
12893 confirmed = Some(stop.summary());
12894 ed.confirm_stop(stop);
12895 }
12896 }
12897 if let Some(what) = confirmed {
12898 self.state.push_log(format!("Route: + {what}"));
12899 }
12900 }
12901
12902 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
12903 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12904 let (pre_npc, pre_template, pre_all) = self
12906 .state
12907 .worker_route_editor
12908 .as_ref()
12909 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12910 .and_then(|stop| match stop {
12911 WorkerRouteStop::TradeWith {
12912 npc_id,
12913 template,
12914 sell_all,
12915 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
12916 _ => None,
12917 })
12918 .unwrap_or((None, None, true));
12919 let npc_id = npc_id.or(pre_npc);
12920 let mut templates = crate::worker_route_editor::sellable_route_item_template_candidates(
12921 &self.re_template_candidates(),
12922 &self.state.npcs,
12923 npc_id.as_deref(),
12924 );
12925 if let Some(template) = pre_template.as_ref() {
12928 if !templates.iter().any(|candidate| candidate == template) {
12929 templates.push(template.clone());
12930 templates.sort();
12931 }
12932 }
12933 if templates.is_empty() {
12934 let msg = crate::worker_route_editor::sell_merchant_empty_reason(
12935 npc_id.as_deref(),
12936 &self.state.npcs,
12937 &self.re_template_candidates(),
12938 );
12939 self.state.push_log(msg);
12940 return;
12941 }
12942 let mut picked = std::collections::BTreeSet::new();
12943 if let Some(t) = pre_template {
12944 picked.insert(t);
12945 }
12946 self.re_open_sheet(S::SellItem {
12947 npc_id,
12948 templates,
12949 index: if picked.is_empty() {
12950 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
12951 } else {
12952 2
12953 },
12954 sell_all: pre_all,
12955 picked,
12956 });
12957 }
12958
12959 fn re_sell_item_activate(&mut self, index: usize) {
12960 use crate::worker_route_editor::{
12961 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12962 };
12963 let mut batch_log: Option<String> = None;
12964 {
12965 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12966 return;
12967 };
12968 let S::SellItem {
12969 npc_id,
12970 templates,
12971 index: sheet_index,
12972 sell_all,
12973 picked,
12974 } = &mut ed.sheet
12975 else {
12976 return;
12977 };
12978 *sheet_index = index;
12979 if index == ROUTE_PICKER_DONE_ROW {
12980 if picked.is_empty() {
12981 batch_log =
12982 Some("Route: pick at least one item (Space toggles, Done confirms)".into());
12983 } else {
12984 let picks: Vec<String> = picked.iter().cloned().collect();
12985 let npc = npc_id.clone();
12986 let all = *sell_all;
12987 let added = ed.confirm_trade_picks(npc, &picks, all);
12988 batch_log = Some(format!("Route: + {added} sell stop(s)"));
12989 }
12990 } else if index == SELL_ITEM_TOGGLE_ROW {
12991 *sell_all = !*sell_all;
12992 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
12993 let sellable = crate::worker_route_editor::sellable_route_item_template_candidates(
12994 std::slice::from_ref(template),
12995 &self.state.npcs,
12996 npc_id.as_deref(),
12997 )
12998 .iter()
12999 .any(|candidate| candidate == template);
13000 if !sellable && !picked.contains(template) {
13001 return;
13002 }
13003 if picked.contains(template) {
13004 picked.remove(template);
13005 } else {
13006 picked.insert(template.clone());
13007 }
13008 }
13009 }
13010 if let Some(msg) = batch_log {
13011 self.state.push_log(msg);
13012 }
13013 }
13014
13015 fn re_open_market_list_item(&mut self) {
13016 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13017 let (pre_hall, pre_template, pre_all) = self
13018 .state
13019 .worker_route_editor
13020 .as_ref()
13021 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13022 .and_then(|stop| match stop {
13023 WorkerRouteStop::ListOnMarket {
13024 hall_id,
13025 template,
13026 list_all,
13027 } => Some((hall_id.clone(), Some(template.clone()), *list_all)),
13028 _ => None,
13029 })
13030 .unwrap_or((None, None, true));
13031 let mut templates = self.re_template_candidates();
13032 templates.sort_by_key(|t| {
13035 std::cmp::Reverse(self.state.item_base_value_copper_hint(t).unwrap_or(0))
13036 });
13037 if let Some(template) = pre_template.as_ref() {
13038 if !templates.iter().any(|c| c == template) {
13039 templates.push(template.clone());
13040 }
13041 }
13042 if templates.is_empty() {
13043 self.state.push_log("Route: no item templates available for market list".to_string());
13044 return;
13045 }
13046 let mut picked = std::collections::BTreeSet::new();
13047 if let Some(t) = pre_template {
13048 picked.insert(t);
13049 }
13050 self.re_open_sheet(S::MarketListItem {
13051 hall_id: pre_hall,
13052 templates,
13053 index: if picked.is_empty() {
13054 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
13055 } else {
13056 2
13057 },
13058 list_all: pre_all,
13059 picked,
13060 });
13061 }
13062
13063 fn re_market_list_item_activate(&mut self, index: usize) {
13064 use crate::worker_route_editor::{
13065 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
13066 };
13067 let mut batch_log: Option<String> = None;
13068 {
13069 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13070 return;
13071 };
13072 let S::MarketListItem {
13073 hall_id,
13074 templates,
13075 index: sheet_index,
13076 list_all,
13077 picked,
13078 } = &mut ed.sheet
13079 else {
13080 return;
13081 };
13082 *sheet_index = index;
13083 if index == ROUTE_PICKER_DONE_ROW {
13084 if picked.is_empty() {
13085 batch_log = Some(
13086 "Route: pick at least one item (Space toggles, Done confirms)".into(),
13087 );
13088 } else {
13089 let picks: Vec<String> = picked.iter().cloned().collect();
13090 let hall = hall_id.clone();
13091 let all = *list_all;
13092 let added = ed.confirm_market_list_picks(hall, &picks, all);
13093 batch_log = Some(format!("Route: + {added} market-list stop(s)"));
13094 }
13095 } else if index == SELL_ITEM_TOGGLE_ROW {
13096 *list_all = !*list_all;
13097 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13098 if picked.contains(template) {
13099 picked.remove(template);
13100 } else {
13101 picked.insert(template.clone());
13102 }
13103 }
13104 }
13105 if let Some(msg) = batch_log {
13106 self.state.push_log(msg);
13107 }
13108 }
13109
13110 pub fn re_edit_selected_stop(&mut self) {
13112 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13113 let Some(stop) = self
13114 .state
13115 .worker_route_editor
13116 .as_ref()
13117 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
13118 else {
13119 self.state
13120 .push_log("Route: no stop selected — press a to add one".to_string());
13121 return;
13122 };
13123 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13124 ed.begin_edit_selected();
13125 }
13126 match stop {
13127 WorkerRouteStop::Waypoint { .. } => {
13128 self.re_open_sheet(S::WaypointMenu { index: 0 });
13129 }
13130 WorkerRouteStop::HarvestNode { node_id } => {
13131 let nodes = self.state.route_editor_node_candidates();
13132 if nodes.is_empty() {
13133 self.re_cancel_edit();
13134 self.state
13135 .push_log("Route: no harvestable nodes visible to retarget".to_string());
13136 } else {
13137 let mut picked = std::collections::BTreeSet::new();
13138 picked.insert(node_id.clone());
13139 let index = nodes
13140 .iter()
13141 .position(|n| n.id == node_id)
13142 .map(|i| i + 1)
13143 .unwrap_or(1);
13144 self.re_open_harvest_picker(index, picked);
13145 }
13146 }
13147 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
13148 let containers = self.re_container_candidates();
13151 if containers.is_empty() {
13152 self.re_cancel_edit();
13153 self.state
13154 .push_log("Route: place a storage chest first".to_string());
13155 } else {
13156 let index = containers
13157 .iter()
13158 .position(|c| c.id == container_id)
13159 .unwrap_or(0);
13160 self.re_open_sheet(S::WithdrawContainers { index });
13161 }
13162 }
13163 WorkerRouteStop::DepositAt { container_id, .. } => {
13164 let containers = self.re_container_candidates();
13165 if containers.is_empty() {
13166 self.re_cancel_edit();
13167 self.state
13168 .push_log("Route: place a storage chest first".to_string());
13169 } else {
13170 let index = containers
13171 .iter()
13172 .position(|c| c.id == container_id)
13173 .unwrap_or(0);
13174 self.re_open_sheet(S::DepositContainers { index });
13175 }
13176 }
13177 WorkerRouteStop::TradeWith { npc_id, .. } => {
13178 let npcs = self.re_npc_candidates();
13179 let index = npc_id
13181 .as_ref()
13182 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
13183 .unwrap_or(0);
13184 self.re_open_sheet(S::SellNpcs { index });
13185 }
13186 WorkerRouteStop::ListOnMarket { .. } => {
13187 self.re_open_market_list_item();
13188 }
13189 WorkerRouteStop::CraftAt { blueprint, .. } => {
13190 let bps = self.re_blueprint_ids();
13191 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
13192 if bps.is_empty() {
13193 self.re_cancel_edit();
13194 self.state
13195 .push_log("Route: no known blueprints to retarget".to_string());
13196 } else {
13197 self.re_open_sheet(S::CraftBlueprint { index });
13198 }
13199 }
13200 WorkerRouteStop::CultivatePlot { .. } => {
13201 self.re_open_farm_plot_picker(
13202 crate::worker_route_editor::FarmPlotAction::Cultivate,
13203 );
13204 }
13205 WorkerRouteStop::PlantPlot { .. } => {
13206 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
13207 }
13208 WorkerRouteStop::HarvestPlot { .. } => {
13209 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
13210 }
13211 WorkerRouteStop::RestIfNeeded => {
13212 self.re_cancel_edit();
13213 self.state
13214 .push_log("Route: rest has no settings (change the bed with l)".to_string());
13215 }
13216 WorkerRouteStop::Wait { wait_ticks } => {
13217 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
13218 }
13219 }
13220 }
13221
13222 fn re_cancel_edit(&mut self) {
13223 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13224 ed.editing_index = None;
13225 }
13226 }
13227
13228 pub fn worker_route_editor_ui_click(
13231 &mut self,
13232 click: crate::worker_route_editor::RouteEditorClick,
13233 ) {
13234 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
13235 match click {
13236 RouteEditorClick::SelectStop(i) => {
13237 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13238 ed.sheet = S::Stops;
13239 ed.select_stop(i);
13240 }
13241 }
13242 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
13243 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
13244 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
13245 }
13246 }
13247
13248 pub fn re_sheet_row_activate(&mut self, row: usize) {
13250 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13251 let Some(sheet) = self
13252 .state
13253 .worker_route_editor
13254 .as_ref()
13255 .map(|ed| ed.sheet.clone())
13256 else {
13257 return;
13258 };
13259 match sheet {
13260 S::Stops => {
13261 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13262 ed.select_stop(row);
13263 }
13264 }
13265 S::AddMenu { .. } => match row {
13266 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
13267 1 => {
13268 if self.re_node_candidates().is_empty() {
13269 self.state.push_log(
13270 "Route: no harvestable nodes visible in this region".to_string(),
13271 );
13272 } else {
13273 self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
13274 }
13275 }
13276 2 | 3 => {
13277 if self.re_container_candidates().is_empty() {
13278 self.state
13279 .push_log("Route: place a storage chest first".to_string());
13280 } else if row == 2 {
13281 self.re_open_sheet(S::WithdrawContainers { index: 0 });
13282 } else {
13283 self.re_open_sheet(S::DepositContainers { index: 0 });
13284 }
13285 }
13286 4 => {
13287 if self.re_template_candidates().is_empty() {
13288 self.state.push_log(
13289 "Route: no item templates available — learn a craft recipe or place a harvest node first"
13290 .to_string(),
13291 );
13292 } else {
13293 self.re_open_sheet(S::SellNpcs { index: 0 });
13294 }
13295 }
13296 5 => {
13297 if self.re_template_candidates().is_empty() {
13298 self.state.push_log(
13299 "Route: no item templates available — learn a craft recipe or place a harvest node first"
13300 .to_string(),
13301 );
13302 } else {
13303 self.re_open_market_list_item();
13304 }
13305 }
13306 6 => {
13307 if self.re_blueprint_ids().is_empty() {
13308 self.state.push_log(
13309 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
13310 .to_string(),
13311 );
13312 } else {
13313 self.re_open_sheet(S::CraftBlueprint { index: 0 });
13314 }
13315 }
13316 7 => self.re_confirm_stop(
13317 WorkerRouteStop::RestIfNeeded,
13318 "rest at lodging (if needed)".into(),
13319 ),
13320 8 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
13321 9 => self.re_open_farm_plot_picker(
13322 crate::worker_route_editor::FarmPlotAction::Cultivate,
13323 ),
13324 10 => {
13325 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant)
13326 }
13327 11 => self
13328 .re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
13329 _ => {}
13330 },
13331 S::WaypointMenu { .. } => match row {
13332 0 => {
13333 let (x, y, z) = self.state.player_position_with_z();
13334 let stop = WorkerRouteStop::Waypoint { x, y, z };
13335 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13336 }
13337 1 => {
13338 self.re_open_sheet(S::WaypointMapPick);
13339 self.state.push_log(
13340 "Route: click the map to place the waypoint (Esc to finish)".to_string(),
13341 );
13342 }
13343 _ => {}
13344 },
13345 S::HarvestPicker { .. } => {
13346 let mut log: Option<String> = None;
13347 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13348 let S::HarvestPicker {
13349 index: sheet_index,
13350 picked,
13351 nodes,
13352 } = &mut ed.sheet
13353 else {
13354 return;
13355 };
13356 *sheet_index = row;
13357 if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
13358 if picked.is_empty() {
13359 log = Some(
13360 "Route: pick at least one node (Space toggles, Done confirms)"
13361 .into(),
13362 );
13363 } else {
13364 let ids: Vec<String> = picked.iter().cloned().collect();
13365 let added = ed.confirm_harvest_picks(&ids);
13366 log = Some(format!("Route: + {added} harvest stop(s)"));
13367 }
13368 } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
13369 if picked.contains(&n.id) {
13370 picked.remove(&n.id);
13371 } else {
13372 picked.insert(n.id.clone());
13373 }
13374 }
13375 }
13376 if let Some(msg) = log {
13377 self.state.push_log(msg);
13378 }
13379 }
13380 S::WithdrawContainers { .. } => {
13381 let containers = self.re_container_candidates();
13382 if let Some(c) = containers.get(row) {
13383 let id = c.id.clone();
13384 self.re_open_withdraw_items(id);
13385 }
13386 }
13387 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
13388 S::DepositContainers { .. } => {
13389 let containers = self.re_container_candidates();
13390 if let Some(c) = containers.get(row) {
13391 let id = c.id.clone();
13392 self.re_open_deposit_filter(id);
13393 }
13394 }
13395 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
13396 S::SellNpcs { .. } => {
13397 let templates = self.re_template_candidates();
13398 let npcs = self.re_npc_candidates();
13399 if row == 0 {
13400 if !crate::worker_route_editor::any_trade_npc_buys_route_item(
13401 &self.state.npcs,
13402 &templates,
13403 ) {
13404 self.state.push_log(
13405 crate::worker_route_editor::sell_merchant_empty_reason(
13406 None,
13407 &self.state.npcs,
13408 &templates,
13409 ),
13410 );
13411 return;
13412 }
13413 self.re_open_sell_item(None);
13414 return;
13415 }
13416 let Some(n) = npcs.get(row - 1) else {
13417 return;
13418 };
13419 if !n.buys_route_item {
13420 self.state.push_log(
13421 crate::worker_route_editor::sell_merchant_empty_reason(
13422 Some(n.id.as_str()),
13423 &self.state.npcs,
13424 &templates,
13425 ),
13426 );
13427 return;
13428 }
13429 self.re_open_sell_item(Some(n.id.clone()));
13430 }
13431 S::SellItem { .. } => self.re_sell_item_activate(row),
13432 S::MarketListItem { .. } => self.re_market_list_item_activate(row),
13433 S::CraftBlueprint { .. } => {
13434 let bps = self.re_blueprint_ids();
13435 if let Some(bp) = bps.get(row) {
13436 let stop = WorkerRouteStop::CraftAt {
13437 device: "hand".into(),
13438 blueprint: bp.clone(),
13439 qty: None,
13440 };
13441 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
13442 }
13443 }
13444 S::WaitEntry { ticks } => {
13445 let stop = WorkerRouteStop::Wait { wait_ticks: ticks };
13446 self.re_confirm_stop(stop, format!("wait {ticks}t"));
13447 }
13448 S::BedPicker { .. } => {
13449 let beds = self.re_bed_candidates();
13450 if let Some((id, name)) = beds.get(row) {
13451 let (id, name) = (id.clone(), name.clone());
13452 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13453 ed.lodging_container_id = Some(id.clone());
13454 ed.sheet = S::Stops;
13455 }
13456 self.state
13457 .push_log(format!("Route: rest bed set to {name}"));
13458 }
13459 }
13460 S::FarmPlotPicker { action, .. } => {
13461 let plots = self.re_farm_plot_candidates();
13462 let Some(plot) = plots.get(row).cloned() else {
13463 return;
13464 };
13465 match action {
13466 crate::worker_route_editor::FarmPlotAction::Cultivate => {
13467 let label = plot_route_label(&plot);
13468 self.re_confirm_stop(
13469 WorkerRouteStop::CultivatePlot {
13470 plot_id: plot.plot_id,
13471 },
13472 format!("cultivate {label}"),
13473 );
13474 }
13475 crate::worker_route_editor::FarmPlotAction::Harvest => {
13476 let label = plot_route_label(&plot);
13477 self.re_confirm_stop(
13478 WorkerRouteStop::HarvestPlot {
13479 plot_id: plot.plot_id,
13480 },
13481 format!("harvest {label}"),
13482 );
13483 }
13484 crate::worker_route_editor::FarmPlotAction::Plant => {
13485 let seeds = self.re_farm_seed_candidates();
13486 if seeds.is_empty() {
13487 self.state.push_log(
13488 "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
13489 );
13490 return;
13491 }
13492 self.re_open_sheet(S::FarmPlantSeed {
13493 plot_id: plot.plot_id,
13494 seeds,
13495 index: 0,
13496 });
13497 }
13498 }
13499 }
13500 S::FarmPlantSeed { plot_id, seeds, .. } => {
13501 if let Some(seed) = seeds.get(row).cloned() {
13502 self.re_confirm_stop(
13503 WorkerRouteStop::PlantPlot {
13504 plot_id,
13505 seed_template: seed.clone(),
13506 },
13507 format!("plant {seed}"),
13508 );
13509 }
13510 }
13511 S::WaypointMapPick => {}
13512 }
13513 }
13514
13515 fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
13516 use crate::worker_route_editor::RouteEditorSheet as S;
13517 if self.re_farm_plot_candidates().is_empty() {
13518 self.state
13519 .push_log("Route: no farmable plots visible — claim land or get farm access first");
13520 return;
13521 }
13522 self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
13523 }
13524
13525 fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
13526 self.state
13527 .property_plots
13528 .iter()
13529 .filter(|p| p.is_mine || p.may_farm)
13530 .cloned()
13531 .collect()
13532 }
13533
13534 fn re_farm_seed_candidates(&self) -> Vec<String> {
13538 let mut set = std::collections::BTreeSet::new();
13539 let looks_like_seed = |id: &str, catalog: &std::collections::HashMap<String, ItemCatalogEntryView>| {
13540 catalog.get(id).is_some_and(|e| e.is_farm_seed()) || id.ends_with("_seed")
13541 };
13542 for (id, _, _) in self.state.farm_seed_entries() {
13543 set.insert(id);
13544 }
13545 for c in &self.state.placed_containers {
13546 let mine = match (self.state.character_id, c.owner_character_id) {
13547 (Some(a), Some(b)) => a == b,
13548 _ => false,
13549 };
13550 if !mine {
13551 continue;
13552 }
13553 for s in &c.contents {
13554 if s.quantity > 0
13555 && (s.props.contains_key("seed_for")
13556 || looks_like_seed(&s.template_id, &self.state.item_catalog))
13557 {
13558 set.insert(s.template_id.clone());
13559 }
13560 }
13561 }
13562 if let Some(ed) = self.state.worker_route_editor.as_ref() {
13563 for stop in &ed.stops {
13564 if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } =
13565 stop
13566 {
13567 for it in items {
13568 if looks_like_seed(&it.template, &self.state.item_catalog) {
13569 set.insert(it.template.clone());
13570 }
13571 }
13572 }
13573 if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
13574 seed_template,
13575 ..
13576 } = stop
13577 {
13578 if !seed_template.is_empty() {
13579 set.insert(seed_template.clone());
13580 }
13581 }
13582 }
13583 }
13584 for (id, entry) in &self.state.item_catalog {
13585 if entry.is_farm_seed() {
13586 set.insert(id.clone());
13587 }
13588 }
13589 set.into_iter().collect()
13590 }
13591
13592 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
13599 use crate::worker_route_editor as wre;
13600 use wre::RouteEditorSheet as S;
13601 if self.state.worker_route_editor.is_none() {
13602 return;
13603 }
13604 let sheet = self
13605 .state
13606 .worker_route_editor
13607 .as_ref()
13608 .map(|ed| ed.sheet.clone())
13609 .unwrap_or(S::Stops);
13610 match sheet {
13611 S::WaypointMapPick => {
13612 let (_, _, z) = self.state.player_position_with_z();
13613 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
13614 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13615 let editing = self
13617 .state
13618 .worker_route_editor
13619 .as_ref()
13620 .is_some_and(|ed| ed.editing_index.is_some());
13621 if !editing {
13622 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13623 ed.sheet = S::WaypointMapPick;
13624 }
13625 }
13626 }
13627 S::HarvestPicker { .. } => {
13628 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13629 let mut log: Option<String> = None;
13630 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13631 let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
13632 return;
13633 };
13634 let selected = if picked.contains(&node.id) {
13635 picked.remove(&node.id);
13636 false
13637 } else {
13638 picked.insert(node.id.clone());
13639 true
13640 };
13641 log = Some(format!(
13642 "Route: {} {}",
13643 if selected { "selected" } else { "deselected" },
13644 resource_node_route_label(node)
13645 ));
13646 }
13647 if let Some(msg) = log {
13648 self.state.push_log(msg);
13649 }
13650 }
13651 }
13652 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
13653 let inside = self.state.effective_inside_building();
13655 if let Some(cid) = wre::pick_storage_container_at(
13656 &self.state.placed_containers,
13657 self.state.character_id,
13658 x,
13659 y,
13660 inside.as_deref(),
13661 ) {
13662 self.re_open_withdraw_items(cid);
13663 }
13664 }
13665 S::DepositContainers { .. } | S::DepositFilter { .. } => {
13666 let inside = self.state.effective_inside_building();
13667 if let Some(cid) = wre::pick_storage_container_at(
13668 &self.state.placed_containers,
13669 self.state.character_id,
13670 x,
13671 y,
13672 inside.as_deref(),
13673 ) {
13674 self.re_open_deposit_filter(cid);
13675 }
13676 }
13677 S::SellNpcs { .. } => {
13678 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13679 self.re_open_sell_item(Some(npc_id));
13680 }
13681 }
13682 S::SellItem { .. } => {
13683 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13684 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13685 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
13686 *slot = Some(npc_id.clone());
13687 }
13688 }
13689 self.state
13690 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13691 }
13692 }
13693 _ => self.worker_route_editor_quick_add_click(x, y),
13695 }
13696 }
13697
13698 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
13702 use crate::worker_route_editor as wre;
13703 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
13704 let dx = ax - bx;
13705 let dy = ay - by;
13706 (dx * dx + dy * dy).sqrt()
13707 };
13708
13709 let selected_stop_kind = self
13712 .state
13713 .worker_route_editor
13714 .as_ref()
13715 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
13716 .map(|s| match s {
13717 wre::WorkerRouteStop::TradeWith { .. } => 1,
13718 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
13719 _ => 0,
13720 })
13721 .unwrap_or(0);
13722 if selected_stop_kind == 1 {
13723 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13724 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13725 ed.set_selected_trade_npc(npc_id.clone());
13726 }
13727 self.state
13728 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13729 return;
13730 }
13731 }
13732 if selected_stop_kind == 2 {
13733 let inside = self.state.effective_inside_building();
13734 if let Some(cid) = wre::pick_storage_container_at(
13735 &self.state.placed_containers,
13736 self.state.character_id,
13737 x,
13738 y,
13739 inside.as_deref(),
13740 ) {
13741 let name = self
13742 .state
13743 .placed_containers
13744 .iter()
13745 .find(|c| c.id == cid)
13746 .map(|c| c.display_name.clone())
13747 .unwrap_or_else(|| "container".into());
13748 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13749 ed.set_selected_withdraw_container(cid.clone());
13750 }
13751 self.state
13752 .push_log(format!("Route: withdraw source → {name}"));
13753 return;
13754 }
13755 }
13756
13757 enum Target {
13760 Bed(String),
13761 Container(String),
13762 Npc(String, String),
13763 Node(String, String),
13764 }
13765 let mut best: Option<(f32, u8, Target)> = None;
13766 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
13767 let better = match best {
13768 None => true,
13769 Some((bd, brank, _)) => {
13770 d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank)
13771 }
13772 };
13773 if better {
13774 *best = Some((d, rank, t));
13775 }
13776 };
13777 let inside = self.state.effective_inside_building();
13778 if let Some(bed_id) = wre::pick_lodging_container_at(
13779 &self.state.placed_containers,
13780 self.state.character_id,
13781 x,
13782 y,
13783 inside.as_deref(),
13784 ) {
13785 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
13786 let already_bed =
13789 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
13790 ed.lodging_container_id.as_deref() == Some(bed_id.as_str())
13791 });
13792 if already_bed {
13793 consider(
13794 dist(x, y, c.x, c.y),
13795 1,
13796 Target::Container(bed_id),
13797 &mut best,
13798 );
13799 } else {
13800 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
13801 }
13802 }
13803 }
13804 if let Some(cid) = wre::pick_storage_container_at(
13805 &self.state.placed_containers,
13806 self.state.character_id,
13807 x,
13808 y,
13809 inside.as_deref(),
13810 ) {
13811 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
13812 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
13813 }
13814 }
13815 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13816 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
13817 consider(
13818 dist(x, y, n.x, n.y),
13819 2,
13820 Target::Npc(npc_id, label),
13821 &mut best,
13822 );
13823 }
13824 }
13825 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13826 let d = dist(x, y, node.x, node.y);
13827 let label = resource_node_route_label(node);
13828 consider(d, 3, Target::Node(node.id.clone(), label), &mut best);
13829 }
13830
13831 match best.map(|(_, _, t)| t) {
13832 Some(Target::Bed(bed_id)) => {
13833 let name = self
13834 .state
13835 .placed_containers
13836 .iter()
13837 .find(|c| c.id == bed_id)
13838 .map(|c| c.display_name.clone())
13839 .unwrap_or_else(|| "camp bed".into());
13840 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13841 ed.lodging_container_id = Some(bed_id.clone());
13842 }
13843 self.state
13844 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
13845 }
13846 Some(Target::Container(cid)) => {
13847 let name = self
13848 .state
13849 .placed_containers
13850 .iter()
13851 .find(|c| c.id == cid)
13852 .map(|c| c.display_name.clone())
13853 .unwrap_or_else(|| "container".into());
13854 let added = self
13855 .state
13856 .worker_route_editor
13857 .as_mut()
13858 .is_some_and(|ed| ed.append_deposit_at(&cid));
13859 if added {
13860 self.state
13861 .push_log(format!("Route: + deposit at {name} ({cid})"));
13862 } else {
13863 self.state.push_log(format!(
13864 "Route: {name} already in route — selected it (d to remove)"
13865 ));
13866 }
13867 }
13868 Some(Target::Npc(npc_id, label)) => {
13869 let template = self.re_template_candidates().into_iter().next();
13872 let Some(template) = template else {
13873 self.state.push_log(
13874 "Route: no items in your storage to sell — stock a chest first".to_string(),
13875 );
13876 return;
13877 };
13878 let added = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
13879 ed.append_trade_with(template.clone(), Some(npc_id.clone()), true)
13880 });
13881 if added {
13882 self.state
13883 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
13884 } else {
13885 self.state.push_log(format!(
13886 "Route: {label} already sells {template} — selected it (d to remove)"
13887 ));
13888 }
13889 }
13890 Some(Target::Node(id, label)) => {
13891 let added = self
13892 .state
13893 .worker_route_editor
13894 .as_mut()
13895 .is_some_and(|ed| ed.append_harvest_node(&id));
13896 if added {
13897 self.state
13898 .push_log(format!("Route: + harvest node {label}"));
13899 } else {
13900 self.state.push_log(format!(
13901 "Route: {label} already in route — selected it (d to remove)"
13902 ));
13903 }
13904 }
13905 None => {}
13906 }
13907 }
13908
13909 pub fn worker_route_editor_select(&mut self, delta: i32) {
13910 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13911 return;
13912 };
13913 if ed.stops.is_empty() {
13914 return;
13915 }
13916 let n = ed.stops.len() as i32;
13917 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
13918 ed.selected_stop_index = next;
13919 }
13920
13921 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
13922 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13923 return;
13924 };
13925 if delta < 0 {
13926 ed.move_selected_up();
13927 } else if delta > 0 {
13928 ed.move_selected_down();
13929 }
13930 }
13931
13932 pub fn worker_route_editor_delete_selected(&mut self) {
13933 let removed = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
13934 let before = ed.stop_count();
13935 ed.remove_selected_stop();
13936 ed.stop_count() < before
13937 });
13938 if removed {
13939 self.state.push_log("Route: removed selected stop");
13940 }
13941 }
13942
13943 pub fn worker_route_editor_clear_stops(&mut self) {
13946 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13947 return;
13948 };
13949 if ed.stops.is_empty() {
13950 self.state
13951 .push_log("Route: already empty — s saves an idle worker".to_string());
13952 return;
13953 }
13954 ed.stops.clear();
13955 ed.selected_stop_index = 0;
13956 self.state.push_log(
13957 "Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string(),
13958 );
13959 }
13960
13961 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
13962 if self.state.pending_worker_job_ack.is_some() {
13963 anyhow::bail!("route save still pending — wait for server ack");
13964 }
13965 let Some(ed) = self.state.worker_route_editor.clone() else {
13966 anyhow::bail!("route editor not open");
13967 };
13968 let (job_yaml, idle) = if ed.stops.is_empty() {
13971 (ed.build_idle_job_yaml(), true)
13972 } else {
13973 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
13974 };
13975 let worker_id = ed.worker_instance_id.clone();
13976 let route_view = if idle { None } else { Some(ed.to_route_view()) };
13977 let mode = if idle {
13978 flatland_protocol::WorkerModeView::Idle
13979 } else {
13980 flatland_protocol::WorkerModeView::JobLoop
13981 };
13982 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
13983 .state
13984 .hired_workers
13985 .iter()
13986 .find(|w| w.instance_id == worker_id)
13987 .map(|w| {
13988 (
13989 w.route.clone(),
13990 w.mode,
13991 w.step_label.clone(),
13992 w.last_error.clone(),
13993 )
13994 })
13995 .unwrap_or((
13996 None,
13997 flatland_protocol::WorkerModeView::Idle,
13998 String::new(),
13999 None,
14000 ));
14001 self.seq += 1;
14002 let seq = self.seq;
14003 self.session
14004 .submit_intent(Intent::SetWorkerJob {
14005 entity_id: self.state.entity_id,
14006 worker_instance_id: worker_id.clone(),
14007 job_yaml,
14008 seq,
14009 })
14010 .await?;
14011 self.state.intents_sent += 1;
14012 if let Some(w) = self
14013 .state
14014 .hired_workers
14015 .iter_mut()
14016 .find(|w| w.instance_id == worker_id)
14017 {
14018 w.route = route_view;
14019 w.mode = mode;
14020 w.last_error = None;
14021 if idle {
14022 w.step_label.clear();
14023 w.route_stop_index = None;
14024 }
14025 }
14026 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
14027 seq,
14028 worker_instance_id: worker_id,
14029 worker_label: ed.worker_label.clone(),
14030 idle,
14031 stop_count: ed.stops.len(),
14032 prev_route,
14033 prev_mode,
14034 prev_step_label,
14035 prev_last_error,
14036 });
14037 self.state.push_log(format!(
14038 "Route: saving for {}… (waiting for server)",
14039 ed.worker_label
14040 ));
14041 Ok(())
14043 }
14044 pub fn quest_menu_move(&mut self, delta: i32) {
14045 let n = self.state.active_quest_entries().len();
14046 if n == 0 {
14047 return;
14048 }
14049 let idx = self.state.quest_menu_index as i32;
14050 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
14051 }
14052
14053 pub fn quest_menu_page(&mut self, pages: i32) {
14054 let n = self.state.active_quest_entries().len();
14055 self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
14056 }
14057
14058 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
14059 let Some(offer) = self.state.selected_quest_offer().cloned() else {
14060 anyhow::bail!("no quest offer");
14061 };
14062 self.seq += 1;
14063 let seq = self.seq;
14064 self.session
14065 .submit_intent(Intent::AcceptQuest {
14066 entity_id: self.state.entity_id,
14067 quest_id: offer.quest_id,
14068 seq,
14069 })
14070 .await?;
14071 self.state.intents_sent += 1;
14072 Ok(())
14073 }
14074
14075 pub fn quest_offer_move(&mut self, delta: i32) {
14076 self.state.move_quest_offer_selection(delta);
14077 }
14078
14079 pub fn quest_offer_decline(&mut self) {
14080 self.state.clear_quest_offers();
14081 if !self.state.show_npc_chat
14082 && !self.state.show_shop_menu
14083 && self.state.npc_verb_target.is_some()
14084 {
14085 self.state.show_npc_verb_menu = true;
14086 }
14087 }
14088
14089 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
14090 if !self.state.show_quest_menu {
14091 return Ok(());
14092 }
14093 let active: Vec<_> = self
14094 .state
14095 .active_quest_entries()
14096 .into_iter()
14097 .cloned()
14098 .collect();
14099 let Some(entry) = active.get(self.state.quest_menu_index) else {
14100 return Ok(());
14101 };
14102 if self.state.quest_withdraw_confirm {
14103 if !entry.can_withdraw {
14104 anyhow::bail!("quest cannot be withdrawn");
14105 }
14106 self.seq += 1;
14107 let seq = self.seq;
14108 self.session
14109 .submit_intent(Intent::WithdrawQuest {
14110 entity_id: self.state.entity_id,
14111 quest_id: entry.quest_id.clone(),
14112 seq,
14113 })
14114 .await?;
14115 self.state.intents_sent += 1;
14116 self.state.quest_withdraw_confirm = false;
14117 return Ok(());
14118 }
14119 self.seq += 1;
14120 let seq = self.seq;
14121 self.session
14122 .submit_intent(Intent::TrackQuest {
14123 entity_id: self.state.entity_id,
14124 quest_id: entry.quest_id.clone(),
14125 seq,
14126 })
14127 .await?;
14128 self.state.intents_sent += 1;
14129 Ok(())
14130 }
14131
14132 pub fn quest_request_withdraw(&mut self) {
14133 if self.state.show_quest_menu {
14134 self.state.quest_withdraw_confirm = true;
14135 }
14136 }
14137
14138 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
14139 if !self.state.is_alive() {
14140 anyhow::bail!("you are dead");
14141 }
14142 let Some(catalog) = self.state.shop_catalog.clone() else {
14143 anyhow::bail!("no shop open");
14144 };
14145 self.seq += 1;
14146 let seq = self.seq;
14147 match self.state.shop_tab {
14148 ShopTab::Buy => {
14149 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
14150 anyhow::bail!("nothing selected");
14151 };
14152 if offer.already_owned {
14153 anyhow::bail!("already owned");
14154 }
14155 self.session
14156 .submit_intent(Intent::ShopBuy {
14157 entity_id: self.state.entity_id,
14158 npc_id: catalog.npc_id.clone(),
14159 offer_id: offer.offer_id.clone(),
14160 quantity: self.state.shop_quantity,
14161 seq,
14162 })
14163 .await?;
14164 }
14165 ShopTab::Sell => {
14166 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
14167 anyhow::bail!("nothing to sell");
14168 };
14169 if line.quantity == 0 {
14170 anyhow::bail!("you have no {}", line.label);
14171 }
14172 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
14173 self.session
14174 .submit_intent(Intent::ShopSell {
14175 entity_id: self.state.entity_id,
14176 npc_id: catalog.npc_id.clone(),
14177 template_id: line.template_id.clone(),
14178 quantity,
14179 seq,
14180 })
14181 .await?;
14182 }
14183 }
14184 self.state.intents_sent += 1;
14185 Ok(())
14186 }
14187
14188 pub fn craft_menu_move(&mut self, delta: i32) {
14189 let n = self.state.craft_filtered_indices().len();
14190 if n == 0 {
14191 return;
14192 }
14193 let idx = self.state.craft_menu_index as i32;
14194 let next = (idx + delta).rem_euclid(n as i32);
14195 self.state.craft_menu_index = next as usize;
14196 self.state.clamp_craft_batch_quantity();
14197 }
14198
14199 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
14200 self.state.craft_batch_adjust_quantity(delta);
14201 }
14202
14203 pub fn craft_batch_set_max(&mut self) {
14204 self.state.craft_batch_set_max();
14205 }
14206
14207 pub fn craft_batch_set_min(&mut self) {
14208 self.state.craft_batch_set_min();
14209 }
14210
14211 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
14212 let Some(blueprint) = self.state.craft_selected_blueprint().cloned() else {
14213 anyhow::bail!("no blueprints in this tab");
14214 };
14215 if !self.state.can_craft_blueprint(&blueprint) {
14216 let hint = self
14217 .state
14218 .craft_missing_hint(&blueprint)
14219 .unwrap_or_else(|| "missing materials".into());
14220 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
14221 }
14222 let count = self.state.craft_batch_quantity;
14223 let max = self.state.max_craft_batches(&blueprint);
14224 if max == 0 {
14225 anyhow::bail!("cannot craft {}", blueprint.label);
14226 }
14227 let batches = count.min(max);
14228 self.craft(&blueprint.id, Some(batches)).await?;
14229 Ok(())
14231 }
14232
14233 pub async fn move_by(
14234 &mut self,
14235 forward: f32,
14236 strafe: f32,
14237 vertical: f32,
14238 sprint: bool,
14239 sneak: bool,
14240 ) -> anyhow::Result<()> {
14241 if !self.state.is_alive() {
14242 anyhow::bail!("you are dead");
14243 }
14244 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
14245 self.last_move_forward = forward;
14246 self.last_move_strafe = strafe;
14247 }
14248 self.seq += 1;
14249 self.session
14250 .submit_intent(Intent::Move {
14251 entity_id: self.state.entity_id,
14252 forward,
14253 strafe,
14254 vertical,
14255 sprint: sprint && !sneak,
14256 sneak,
14257 seq: self.seq,
14258 })
14259 .await?;
14260 self.state.intents_sent += 1;
14261 Ok(())
14262 }
14263
14264 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
14265 if !self.state.connected {
14266 crate::harvest_trace!("harvest_nearest rejected: not connected");
14267 anyhow::bail!("not connected");
14268 }
14269 if !self.state.is_alive() {
14270 crate::harvest_trace!("harvest_nearest rejected: player dead");
14271 anyhow::bail!("you are dead");
14272 }
14273 if self.state.harvest_in_progress {
14274 if self.state.harvest_state_stale() {
14275 self.state.clear_harvest_state();
14276 } else {
14277 anyhow::bail!("already harvesting");
14278 }
14279 }
14280 let (px, py) = self
14281 .state
14282 .player
14283 .as_ref()
14284 .map(|p| (p.transform.position.x, p.transform.position.y))
14285 .unwrap_or((0.0, 0.0));
14286
14287 let available = self
14288 .state
14289 .resource_nodes
14290 .iter()
14291 .filter(|n| !n.harvest_off)
14292 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14293 .count();
14294 let node_id = self
14295 .state
14296 .resource_nodes
14297 .iter()
14298 .filter(|n| !n.harvest_off)
14299 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14300 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
14301 .min_by(|a, b| {
14302 let da = distance(px, py, a.x, a.y);
14303 let db = distance(px, py, b.x, b.y);
14304 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
14305 })
14306 .map(|n| n.id.clone());
14307
14308 let Some(node_id) = node_id else {
14309 let has_loot = self
14310 .state
14311 .ground_drops
14312 .iter()
14313 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14314 if has_loot {
14315 return self.pickup_nearest().await;
14316 }
14317 anyhow::bail!(
14318 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
14319 );
14320 };
14321
14322 self.seq += 1;
14323 let seq = self.seq;
14324 crate::harvest_trace!(
14325 entity_id = self.state.entity_id,
14326 node_id = %node_id,
14327 seq,
14328 px,
14329 py,
14330 available_nodes = available,
14331 "submitting harvest intent"
14332 );
14333 self.session
14334 .submit_intent(Intent::Harvest {
14335 entity_id: self.state.entity_id,
14336 node_id,
14337 seq,
14338 })
14339 .await?;
14340 self.state.intents_sent += 1;
14341 self.state.harvest_in_progress = true;
14342 self.state.harvest_started_at = Some(Instant::now());
14343 self.state.push_log("Harvesting…");
14344 crate::harvest_trace!(
14345 entity_id = self.state.entity_id,
14346 seq,
14347 "harvest intent queued to session"
14348 );
14349 Ok(())
14350 }
14351
14352 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
14353 if !self.state.is_alive() {
14354 anyhow::bail!("you are dead");
14355 }
14356 let blueprint_id = self
14357 .state
14358 .blueprints
14359 .iter()
14360 .find(|bp| self.state.can_craft_blueprint(bp))
14361 .map(|bp| bp.id.clone())
14362 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
14363 self.craft(&blueprint_id, None).await
14364 }
14365
14366 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
14367 if !self.state.is_alive() {
14368 anyhow::bail!("you are dead");
14369 }
14370 self.seq += 1;
14371 self.session
14372 .submit_intent(Intent::Craft {
14373 entity_id: self.state.entity_id,
14374 blueprint_id: blueprint_id.to_string(),
14375 count,
14376 seq: self.seq,
14377 })
14378 .await?;
14379 self.state.intents_sent += 1;
14380 let (label, batches) = self
14381 .state
14382 .blueprints
14383 .iter()
14384 .find(|b| b.id == blueprint_id)
14385 .map(|b| {
14386 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
14387 (b.label.as_str(), n)
14388 })
14389 .unwrap_or((blueprint_id, count.unwrap_or(1)));
14390 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
14391 self.state.craft_channel_blueprint_id = Some(blueprint_id.to_string());
14392 Ok(())
14393 }
14394
14395 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
14396 if !self.state.is_alive() {
14397 anyhow::bail!("you are dead");
14398 }
14399 let target_id = match self.state.nearest_interact_target() {
14400 Some(id) => id,
14401 None => {
14402 anyhow::bail!("nothing to interact with nearby");
14403 }
14404 };
14405 if self.state.npcs.iter().any(|n| n.id == target_id) {
14406 self.state.show_npc_verb_menu = true;
14407 self.state.npc_verb_target = Some(target_id);
14408 self.state.npc_verb_index = 0;
14409 self.state.npc_verb_notice = None;
14410 return Ok(());
14411 }
14412 if self
14413 .state
14414 .hired_workers
14415 .iter()
14416 .any(|w| w.instance_id == target_id)
14417 {
14418 return self.open_workers_menu_for(&target_id).await;
14419 }
14420 if let Ok(peer_id) = target_id.parse::<EntityId>() {
14421 if self
14422 .state
14423 .hired_workers
14424 .iter()
14425 .any(|w| w.entity_id == peer_id)
14426 {
14427 if let Some(w) = self
14428 .state
14429 .hired_workers
14430 .iter()
14431 .find(|w| w.entity_id == peer_id)
14432 {
14433 let id = w.instance_id.clone();
14434 return self.open_workers_menu_for(&id).await;
14435 }
14436 }
14437 if let Some(entity) = self
14438 .state
14439 .entities
14440 .iter()
14441 .find(|e| e.id == peer_id && e.id != self.state.entity_id)
14442 {
14443 self.state.player_verbs.open_for(peer_id, &entity.label);
14444 return Ok(());
14445 }
14446 }
14447 self.seq += 1;
14448 self.session
14449 .submit_intent(Intent::Interact {
14450 entity_id: self.state.entity_id,
14451 target_id: target_id.clone(),
14452 seq: self.seq,
14453 })
14454 .await?;
14455 self.state.intents_sent += 1;
14456 Ok(())
14457 }
14458
14459 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
14461 if !self.state.is_alive() {
14462 anyhow::bail!("you are dead");
14463 }
14464 let (px, py) = self.state.player_position();
14465 let has_loot = self
14466 .state
14467 .ground_drops
14468 .iter()
14469 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14470 if has_loot {
14471 return self.pickup_nearest().await;
14472 }
14473 if self
14474 .state
14475 .placed_containers
14476 .iter()
14477 .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
14478 {
14479 return self.pickup_nearest_container().await;
14480 }
14481
14482 if self.state.harvestable_node_in_range() {
14484 return self.harvest_nearest().await;
14485 }
14486
14487 if let Some(plot) = self.state.my_plot_under_player().cloned() {
14488 const SELL_WINDOW: Duration = Duration::from_millis(1200);
14490 let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
14491 && self
14492 .state
14493 .sell_plot_armed_at
14494 .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
14495 if sell_armed {
14496 return self.confirm_sell_plot_to_crown(plot.plot_id).await;
14497 }
14498 self.state.sell_plot_confirm = None;
14499 self.state.sell_plot_armed_at = None;
14500
14501 let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
14504 self.state.npcs.iter().any(|n| n.id == id)
14505 || self.state.hired_workers.iter().any(|w| w.instance_id == id)
14506 || self.state.doors.iter().any(|d| d.id == id)
14507 || self.state.interactables.iter().any(|i| {
14508 i.id == id
14509 && matches!(i.kind.as_str(), "quest_board" | "well" | "exit" | "enter")
14510 })
14511 || id.parse::<EntityId>().is_ok_and(|eid| {
14512 self.state
14513 .entities
14514 .iter()
14515 .any(|e| e.id == eid && e.id != self.state.entity_id)
14516 })
14517 });
14518 if !blocking_interact {
14519 match self.harvest_nearest().await {
14521 Ok(()) => return Ok(()),
14522 Err(err) => {
14523 let msg = err.to_string();
14524 if !(msg.contains("no harvestable")
14525 || msg.contains("press p")
14526 || msg.contains("press f")
14527 || msg.contains("nothing"))
14528 {
14529 return Err(err);
14530 }
14531 }
14532 }
14533 return Ok(());
14534 }
14535 }
14536 if self.state.nearest_interact_target().is_some() {
14537 return self.interact_nearest().await;
14538 }
14539 if let Some((label, dist)) = self.state.nearest_quest_board() {
14542 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
14543 anyhow::bail!(
14544 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
14545 );
14546 }
14547 }
14548
14549 match self.harvest_nearest().await {
14550 Ok(()) => Ok(()),
14551 Err(err) => {
14552 let msg = err.to_string();
14553 if msg.contains("no harvestable")
14554 || msg.contains("press p")
14555 || msg.contains("press f")
14556 {
14557 anyhow::bail!(
14558 "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
14559 );
14560 }
14561 Err(err)
14562 }
14563 }
14564 }
14565
14566 pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
14568 if !self.state.is_alive() {
14569 anyhow::bail!("you are dead");
14570 }
14571 if self.state.claim_mode.is_some() {
14572 anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
14573 }
14574 let zone = self
14575 .state
14576 .free_property_zone_under_player()
14577 .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
14578 let zone_id = zone.id.clone();
14579 let label = zone
14580 .label
14581 .as_deref()
14582 .filter(|s| !s.trim().is_empty())
14583 .unwrap_or(zone.id.as_str())
14584 .to_string();
14585 self.enter_claim_mode(&zone_id);
14586 self.state.push_log(format!(
14587 "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
14588 ));
14589 Ok(())
14590 }
14591
14592 pub fn enter_claim_mode(&mut self, zone_id: &str) {
14594 let Some(zone) = self
14595 .state
14596 .property_zones
14597 .iter()
14598 .find(|z| z.id == zone_id)
14599 .cloned()
14600 else {
14601 self.state.push_log("unknown property zone");
14602 return;
14603 };
14604 self.state.sell_plot_confirm = None;
14605 self.state.sell_plot_armed_at = None;
14606 let min_area = self
14607 .state
14608 .property_plot_settings
14609 .as_ref()
14610 .map(|s| s.min_plot_area_m2)
14611 .unwrap_or(4.0)
14612 .max(1.0);
14613 let min_side = min_area.sqrt().ceil().max(1.0) as u32;
14614 let side = 4u32.max(min_side);
14615 let (px, py) = self.state.player_position();
14616 let anchor_x = px.floor();
14617 let anchor_y = py.floor();
14618 self.state.claim_mode = Some(ClaimModeState {
14619 zone_id: zone.id.clone(),
14620 width_m: side,
14621 height_m: side,
14622 anchor_x,
14623 anchor_y,
14624 });
14625 let label = zone
14626 .label
14627 .as_deref()
14628 .filter(|s| !s.trim().is_empty())
14629 .unwrap_or(zone.id.as_str());
14630 self.state.push_log(format!(
14631 "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
14632 ));
14633 }
14634
14635 pub fn cancel_claim_mode(&mut self) {
14636 if self.state.claim_mode.take().is_some() {
14637 self.state.push_log("Claim cancelled");
14638 }
14639 }
14640
14641 pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
14643 if !self.state.is_alive() {
14644 anyhow::bail!("you are dead");
14645 }
14646 if self.state.relocate_mode.is_some() {
14647 anyhow::bail!("already relocating — Enter confirm, Esc cancel");
14648 }
14649 if self.state.claim_mode.is_some() {
14650 anyhow::bail!("finish or cancel claim mode first");
14651 }
14652 let chest = self
14653 .state
14654 .placed_containers
14655 .iter()
14656 .find(|c| c.id == container_id)
14657 .cloned()
14658 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
14659 let (px, py) = self.state.player_position();
14660 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
14661 anyhow::bail!("too far from {}", chest.display_name);
14662 }
14663 if chest.locked && !chest.accessible {
14664 anyhow::bail!(
14665 "need the matching key for {} before moving it",
14666 chest.display_name
14667 );
14668 }
14669 let label = if chest.display_name.trim().is_empty() {
14670 chest.template_id.clone()
14671 } else {
14672 chest.display_name.clone()
14673 };
14674 self.state.relocate_mode = Some(RelocateModeState {
14675 container_id: chest.id.clone(),
14676 label: label.clone(),
14677 cursor_x: chest.x.floor() + 0.5,
14678 cursor_y: chest.y.floor() + 0.5,
14679 });
14680 self.state.push_log(format!(
14681 "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
14682 ));
14683 Ok(())
14684 }
14685
14686 pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
14688 let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
14689 anyhow::bail!("no chest nearby to relocate");
14690 };
14691 if chest.locked && !chest.accessible {
14692 anyhow::bail!(
14693 "need the matching key for {} before moving it",
14694 chest.display_name
14695 );
14696 }
14697 self.begin_relocate_container(&chest.id)
14700 }
14701
14702 pub fn cancel_relocate_mode(&mut self) {
14703 if self.state.relocate_mode.take().is_some() {
14704 self.state.push_log("Relocate cancelled");
14705 }
14706 }
14707
14708 pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
14709 let Some(mode) = self.state.relocate_mode.as_mut() else {
14710 return;
14711 };
14712 let max_x = self.state.world_width_m.max(1.0);
14713 let max_y = self.state.world_height_m.max(1.0);
14714 let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
14715 let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
14716 mode.cursor_x = nx.floor() + 0.5;
14717 mode.cursor_y = ny.floor() + 0.5;
14718 }
14719
14720 pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
14721 let Some(mode) = self.state.relocate_mode.as_mut() else {
14722 return;
14723 };
14724 let max_x = self.state.world_width_m.max(1.0);
14725 let max_y = self.state.world_height_m.max(1.0);
14726 mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
14727 mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
14728 }
14729
14730 pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
14731 if !self.state.is_alive() {
14732 anyhow::bail!("you are dead");
14733 }
14734 let Some(mode) = self.state.relocate_mode.clone() else {
14735 anyhow::bail!("not relocating");
14736 };
14737 let (px, py) = self.state.player_position();
14738 let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
14739 if dist > 8.0 {
14740 anyhow::bail!("destination too far (max 8 m)");
14741 }
14742 self.seq += 1;
14743 self.session
14744 .submit_intent(Intent::MovePlacedContainer {
14745 entity_id: self.state.entity_id,
14746 container_id: mode.container_id.clone(),
14747 x: mode.cursor_x,
14748 y: mode.cursor_y,
14749 seq: self.seq,
14750 })
14751 .await?;
14752 self.state.intents_sent += 1;
14753 self.state.relocate_mode = None;
14754 self.state.push_log(format!("Moving {}…", mode.label));
14755 Ok(())
14756 }
14757
14758 pub fn claim_set_preset(&mut self, w: u32, h: u32) {
14759 let Some(mode) = self.state.claim_mode.as_mut() else {
14760 return;
14761 };
14762 mode.width_m = w.max(1);
14763 mode.height_m = h.max(1);
14764 }
14765
14766 pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
14767 let Some(mode) = self.state.claim_mode.as_mut() else {
14768 return;
14769 };
14770 let w = (mode.width_m as i32 + dw).max(1) as u32;
14771 let h = (mode.height_m as i32 + dh).max(1) as u32;
14772 mode.width_m = w;
14773 mode.height_m = h;
14774 }
14775
14776 pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
14778 let Some(mode) = self.state.claim_mode.as_mut() else {
14779 return;
14780 };
14781 let max_x = self.state.world_width_m.max(1.0);
14782 let max_y = self.state.world_height_m.max(1.0);
14783 let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
14784 let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
14785 mode.anchor_x = nx.floor();
14786 mode.anchor_y = ny.floor();
14787 }
14788
14789 pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
14790 if !self.state.is_alive() {
14791 anyhow::bail!("you are dead");
14792 }
14793 let Some(mode) = self.state.claim_mode.clone() else {
14794 anyhow::bail!("not in claim mode");
14795 };
14796 let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
14797 self.state.claim_quote()
14798 else {
14799 anyhow::bail!("cannot quote claim");
14800 };
14801 if !valid {
14802 anyhow::bail!(reason);
14803 }
14804 if !can_afford {
14805 anyhow::bail!(
14806 "not enough copper (need {})",
14807 crate::currency::format_copper(purchase)
14808 );
14809 }
14810 let (x0, y0, x1, y1) = self
14811 .state
14812 .claim_footprint_rect()
14813 .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
14814 let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
14815 self.seq += 1;
14816 self.session
14817 .submit_intent(Intent::BuyPlot {
14818 entity_id: self.state.entity_id,
14819 zone_id: mode.zone_id,
14820 x0,
14821 y0,
14822 x1,
14823 y1,
14824 seq: self.seq,
14825 })
14826 .await?;
14827 self.state.intents_sent += 1;
14828 self.state.claim_mode = None;
14829 self.state.push_log(format!(
14830 "Buying plot for {}",
14831 crate::currency::format_copper(purchase)
14832 ));
14833 Ok(())
14834 }
14835
14836 pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
14837 if !self.state.is_alive() {
14838 anyhow::bail!("you are dead");
14839 }
14840 let zone_id = self
14841 .state
14842 .claim_mode
14843 .as_ref()
14844 .map(|m| m.zone_id.clone())
14845 .or_else(|| {
14846 self.state
14847 .free_property_zone_under_player()
14848 .map(|z| z.id.clone())
14849 })
14850 .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
14851 self.seq += 1;
14852 self.session
14853 .submit_intent(Intent::BuyPlotAllFree {
14854 entity_id: self.state.entity_id,
14855 zone_id,
14856 seq: self.seq,
14857 })
14858 .await?;
14859 self.state.intents_sent += 1;
14860 self.state.claim_mode = None;
14861 self.state.push_log("Claiming largest free plot…");
14862 Ok(())
14863 }
14864
14865 pub async fn confirm_sell_plot_to_crown(&mut self, plot_id: uuid::Uuid) -> anyhow::Result<()> {
14866 if !self.state.is_alive() {
14867 anyhow::bail!("you are dead");
14868 }
14869 self.seq += 1;
14870 self.session
14871 .submit_intent(Intent::SellPlotToCrown {
14872 entity_id: self.state.entity_id,
14873 plot_id,
14874 seq: self.seq,
14875 })
14876 .await?;
14877 self.state.intents_sent += 1;
14878 self.state.sell_plot_confirm = None;
14879 self.state.sell_plot_armed_at = None;
14880 self.state.push_log("Selling plot to the crown…");
14881 Ok(())
14882 }
14883
14884 pub async fn set_plot_farm_public(
14885 &mut self,
14886 plot_id: uuid::Uuid,
14887 public: bool,
14888 public_tax_discount_bps: u32,
14889 ) -> anyhow::Result<()> {
14890 self.seq += 1;
14891 self.session
14892 .submit_intent(Intent::SetPlotFarmPublic {
14893 entity_id: self.state.entity_id,
14894 plot_id,
14895 public,
14896 public_tax_discount_bps,
14897 seq: self.seq,
14898 })
14899 .await?;
14900 self.state.intents_sent += 1;
14901 Ok(())
14902 }
14903
14904 pub async fn plot_farm_allow_upsert(
14905 &mut self,
14906 plot_id: uuid::Uuid,
14907 character_id: Option<uuid::Uuid>,
14908 character_name: String,
14909 tax_discount_bps: u32,
14910 ) -> anyhow::Result<()> {
14911 self.seq += 1;
14912 self.session
14913 .submit_intent(Intent::PlotFarmAllowUpsert {
14914 entity_id: self.state.entity_id,
14915 plot_id,
14916 character_id,
14917 character_name,
14918 tax_discount_bps,
14919 seq: self.seq,
14920 })
14921 .await?;
14922 self.state.intents_sent += 1;
14923 Ok(())
14924 }
14925
14926 pub async fn plot_farm_allow_remove(
14927 &mut self,
14928 plot_id: uuid::Uuid,
14929 character_id: uuid::Uuid,
14930 ) -> anyhow::Result<()> {
14931 self.seq += 1;
14932 self.session
14933 .submit_intent(Intent::PlotFarmAllowRemove {
14934 entity_id: self.state.entity_id,
14935 plot_id,
14936 character_id,
14937 seq: self.seq,
14938 })
14939 .await?;
14940 self.state.intents_sent += 1;
14941 Ok(())
14942 }
14943
14944 pub fn open_farm_access_panel(&mut self) {
14945 let Some(plot) = self.state.my_plot_under_player() else {
14946 self.state
14947 .push_log("Stand on your deed plot to manage farm access");
14948 return;
14949 };
14950 self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
14951 self.state.farm_access_index = 0;
14952 self.state.show_farm_access = true;
14953 }
14954
14955 pub fn close_farm_access_panel(&mut self) {
14956 self.state.show_farm_access = false;
14957 self.state.farm_access_name_draft.clear();
14958 self.state.farm_access_index = 0;
14959 }
14960
14961 pub fn farm_access_move(&mut self, delta: i32) {
14962 let n = self.farm_access_row_count().max(1);
14963 let idx = self.state.farm_access_index as i32 + delta;
14964 self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
14965 }
14966
14967 pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
14968 let Some(plot) = self.state.my_plot_under_player() else {
14969 return vec![FarmAccessRow::PublicToggle];
14970 };
14971 let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
14972 for g in &plot.farm_allow {
14973 rows.push(FarmAccessRow::AllowRemove {
14974 character_id: g.character_id,
14975 label: if g.character_label.trim().is_empty() {
14976 g.character_id.to_string()[..8].to_string()
14977 } else {
14978 g.character_label.clone()
14979 },
14980 tax_discount_bps: g.tax_discount_bps,
14981 });
14982 }
14983 for e in &self.state.entities {
14984 if e.id == self.state.entity_id || e.label.trim().is_empty() {
14985 continue;
14986 }
14987 if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
14988 continue;
14989 }
14990 if self
14991 .state
14992 .npcs
14993 .iter()
14994 .any(|n| n.id == e.label || n.label == e.label)
14995 {
14996 continue;
14997 }
14998 if plot
14999 .farm_allow
15000 .iter()
15001 .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
15002 {
15003 continue;
15004 }
15005 rows.push(FarmAccessRow::NearbyAdd {
15006 name: e.label.clone(),
15007 });
15008 }
15009 rows
15010 }
15011
15012 pub fn farm_access_row_count(&self) -> usize {
15013 self.farm_access_rows().len().max(1)
15014 }
15015
15016 pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
15017 let Some(plot) = self.state.my_plot_under_player().cloned() else {
15018 self.close_farm_access_panel();
15019 return Ok(());
15020 };
15021 let rows = self.farm_access_rows();
15022 let Some(row) = rows.get(self.state.farm_access_index) else {
15023 return Ok(());
15024 };
15025 match row {
15026 FarmAccessRow::PublicToggle => {
15027 self.set_plot_farm_public(
15028 plot.plot_id,
15029 !plot.farm_public,
15030 plot.public_tax_discount_bps,
15031 )
15032 .await
15033 }
15034 FarmAccessRow::PublicDiscount => Ok(()),
15035 FarmAccessRow::AllowRemove { character_id, .. } => {
15036 self.plot_farm_allow_remove(plot.plot_id, *character_id)
15037 .await
15038 }
15039 FarmAccessRow::NearbyAdd { name } => {
15040 let disc = self
15041 .state
15042 .farm_access_discount_bps
15043 .max(plot.public_tax_discount_bps);
15044 self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
15045 .await
15046 }
15047 }
15048 }
15049
15050 pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
15051 let Some(plot) = self.state.my_plot_under_player().cloned() else {
15052 return Ok(());
15053 };
15054 let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
15055 self.state.farm_access_discount_bps = next;
15056 self.state.farm_access_index = 1;
15057 self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
15058 .await
15059 }
15060
15061 pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
15063 if self.state.farmable_plot_under_player().is_none() {
15064 anyhow::bail!("stand on a farmable plot to cultivate");
15065 }
15066 let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
15067 let (px, py) = self.state.player_position();
15068 if self
15069 .state
15070 .terrain_at(px, py)
15071 .is_some_and(|k| k == TerrainKindView::Tilled)
15072 {
15073 anyhow::bail!("already tilled — stand on bare soil and press c");
15074 }
15075 anyhow::bail!("cannot till this cell — move onto soil on your plot");
15076 };
15077 self.cultivate_at(tx, ty).await
15078 }
15079
15080 pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
15082 if self.state.farmable_plot_under_player().is_none() {
15083 anyhow::bail!("stand on a farmable plot to plant");
15084 }
15085 if !self.state.underfoot_free_tilled_plant_slot() {
15086 anyhow::bail!("stand on empty tilled soil and press p");
15087 }
15088 let seeds = self.state.farm_seed_entries();
15089 if seeds.is_empty() {
15090 anyhow::bail!("no seeds in inventory — buy seeds from Eli");
15091 }
15092 if seeds.len() == 1 {
15093 return self.plant_seeds(seeds[0].0.clone(), 1).await;
15094 }
15095 self.open_plant_menu();
15096 Ok(())
15097 }
15098
15099 pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
15101 let Some(plot) = self.state.my_plot_under_player() else {
15102 anyhow::bail!("stand on your plot to build");
15103 };
15104 if plot.building_id.is_some() {
15105 anyhow::bail!("this plot already has a building");
15106 }
15107 let building_now = self
15108 .state
15109 .timed_channel
15110 .as_ref()
15111 .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
15112 if !building_now && self.state.building_materials.is_empty() {
15113 anyhow::bail!("no building materials loaded — wait a moment and try again");
15114 }
15115 self.state.show_plot_build_menu = true;
15116 self.state.show_craft_menu = false;
15117 self.state.show_shop_menu = false;
15118 self.state.shop_catalog = None;
15119 self.state.show_stats = false;
15120 self.state.show_inventory_menu = false;
15121 self.state.plot_build_focus_wall = true;
15122 let walls = self.state.plot_build_wall_options().len();
15123 let roofs = self.state.plot_build_roof_options().len();
15124 if walls > 0 {
15125 self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
15126 } else {
15127 self.state.plot_build_wall_index = 0;
15128 }
15129 if roofs > 0 {
15130 self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
15131 } else {
15132 self.state.plot_build_roof_index = 0;
15133 }
15134 Ok(())
15135 }
15136
15137 pub fn close_plot_build_menu(&mut self) {
15138 self.state.show_plot_build_menu = false;
15139 }
15140
15141 pub fn plot_build_menu_move(&mut self, delta: i32) {
15142 let walls = self.state.plot_build_wall_options();
15143 let roofs = self.state.plot_build_roof_options();
15144 if self.state.plot_build_focus_wall {
15145 if walls.is_empty() {
15146 return;
15147 }
15148 let n = walls.len() as i32;
15149 let cur = self.state.plot_build_wall_index as i32;
15150 self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
15151 } else {
15152 if roofs.is_empty() {
15153 return;
15154 }
15155 let n = roofs.len() as i32;
15156 let cur = self.state.plot_build_roof_index as i32;
15157 self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
15158 }
15159 }
15160
15161 pub fn plot_build_menu_toggle_focus(&mut self) {
15162 self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
15163 }
15164
15165 pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
15167 let wall = self
15168 .state
15169 .plot_build_selected_wall()
15170 .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
15171 .id
15172 .clone();
15173 let roof = self
15174 .state
15175 .plot_build_selected_roof()
15176 .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
15177 .id
15178 .clone();
15179 self.start_plot_build(&wall, &roof).await
15181 }
15182
15183 pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
15185 self.seq += 1;
15186 self.session
15187 .submit_intent(Intent::CancelPlotBuild {
15188 entity_id: self.state.entity_id,
15189 seq: self.seq,
15190 })
15191 .await?;
15192 self.state.intents_sent += 1;
15193 Ok(())
15194 }
15195
15196 pub async fn start_plot_build(
15198 &mut self,
15199 wall_material_id: &str,
15200 roof_material_id: &str,
15201 ) -> anyhow::Result<()> {
15202 let Some(plot) = self.state.my_plot_under_player() else {
15203 anyhow::bail!("stand on your plot to build");
15204 };
15205 if plot.building_id.is_some() {
15206 anyhow::bail!("this plot already has a building");
15207 }
15208 let plot_id = plot.plot_id;
15209 self.seq += 1;
15210 self.session
15211 .submit_intent(Intent::StartPlotBuild {
15212 entity_id: self.state.entity_id,
15213 plot_id,
15214 wall_material_id: wall_material_id.to_string(),
15215 roof_material_id: roof_material_id.to_string(),
15216 seq: self.seq,
15217 })
15218 .await?;
15219 self.state.intents_sent += 1;
15220 Ok(())
15221 }
15222
15223 pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
15225 let (px, py) = self.state.player_position();
15226 let mut best: Option<(f32, String, bool)> = None;
15227 for d in &self.state.doors {
15228 if d.lock_id.is_none() {
15229 continue;
15230 }
15231 let dist = (d.x - px).hypot(d.y - py);
15232 if dist > DOOR_INTERACTION_RADIUS_M {
15233 continue;
15234 }
15235 if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
15236 best = Some((dist, d.id.clone(), d.locked));
15237 }
15238 }
15239 let Some((_, door_id, locked_now)) = best else {
15240 anyhow::bail!("no lockable door nearby");
15241 };
15242 let locked = !locked_now;
15243 self.seq += 1;
15244 self.session
15245 .submit_intent(Intent::SetDoorLocked {
15246 entity_id: self.state.entity_id,
15247 door_id,
15248 locked,
15249 seq: self.seq,
15250 })
15251 .await?;
15252 self.state.intents_sent += 1;
15253 Ok(())
15254 }
15255
15256 pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
15258 if !self.state.is_alive() {
15259 anyhow::bail!("you are dead");
15260 }
15261 if self.state.effective_inside_building().is_some() {
15262 anyhow::bail!("already inside");
15263 }
15264 let (px, py) = self.state.player_position();
15265 let mut best: Option<(f32, String)> = None;
15266 for d in &self.state.doors {
15267 if !d.open || d.locked {
15268 continue;
15269 }
15270 let player_house = self
15271 .state
15272 .buildings
15273 .iter()
15274 .find(|b| b.id == d.building_id)
15275 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15276 if !player_house {
15277 continue;
15278 }
15279 let dist = (d.x - px).hypot(d.y - py);
15280 if dist > DOOR_INTERACTION_RADIUS_M {
15281 continue;
15282 }
15283 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15284 best = Some((dist, d.id.clone()));
15285 }
15286 }
15287 let Some((_, door_id)) = best else {
15288 anyhow::bail!("no open house door nearby — open with f first");
15289 };
15290 self.seq += 1;
15291 self.session
15292 .submit_intent(Intent::EnterBuildingDoor {
15293 entity_id: self.state.entity_id,
15294 door_id,
15295 seq: self.seq,
15296 })
15297 .await?;
15298 self.state.intents_sent += 1;
15299 Ok(())
15300 }
15301
15302 pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
15305 if !self.state.is_alive() {
15306 anyhow::bail!("you are dead");
15307 }
15308 let Some(bid) = self.state.effective_inside_building() else {
15309 anyhow::bail!("not inside a building");
15310 };
15311 let (px, py) = self.state.player_position();
15312 let mut best: Option<(f32, String)> = None;
15313 for d in &self.state.doors {
15314 if d.building_id != bid || d.portal.is_none() {
15315 continue;
15316 }
15317 let player_house = self
15318 .state
15319 .buildings
15320 .iter()
15321 .find(|b| b.id == d.building_id)
15322 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15323 if !player_house {
15324 continue;
15325 }
15326 let dist = (d.x - px).hypot(d.y - py);
15327 if dist > 1.5 {
15328 continue;
15329 }
15330 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15331 best = Some((dist, d.id.clone()));
15332 }
15333 }
15334 let Some((_, door_id)) = best else {
15335 anyhow::bail!("stand by the door to exit");
15336 };
15337 self.seq += 1;
15338 self.session
15339 .submit_intent(Intent::ExitBuildingDoor {
15340 entity_id: self.state.entity_id,
15341 door_id,
15342 seq: self.seq,
15343 })
15344 .await?;
15345 self.state.intents_sent += 1;
15346 Ok(())
15347 }
15348
15349 pub async fn confirm_interior_edit(
15351 &mut self,
15352 building_id: String,
15353 rooms: Vec<flatland_protocol::InteriorRoomEdit>,
15354 room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
15355 ) -> anyhow::Result<()> {
15356 self.seq += 1;
15357 self.session
15358 .submit_intent(Intent::ConfirmInteriorEdit {
15359 entity_id: self.state.entity_id,
15360 building_id,
15361 rooms,
15362 room_doors,
15363 seq: self.seq,
15364 })
15365 .await?;
15366 self.state.intents_sent += 1;
15367 Ok(())
15368 }
15369
15370 pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
15371 if !self.state.is_alive() {
15372 anyhow::bail!("you are dead");
15373 }
15374 self.seq += 1;
15375 self.session
15376 .submit_intent(Intent::Cultivate {
15377 entity_id: self.state.entity_id,
15378 x,
15379 y,
15380 seq: self.seq,
15381 })
15382 .await?;
15383 self.state.intents_sent += 1;
15384 Ok(())
15385 }
15386
15387 pub async fn plant_seeds(
15388 &mut self,
15389 seed_template_id: String,
15390 quantity: u32,
15391 ) -> 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::PlantSeeds {
15398 entity_id: self.state.entity_id,
15399 seed_template_id: seed_template_id.clone(),
15400 quantity,
15401 seq: self.seq,
15402 })
15403 .await?;
15404 self.state.intents_sent += 1;
15405 self.state
15406 .push_log(format!("Planting {quantity}× {seed_template_id}…"));
15407 Ok(())
15408 }
15409
15410 pub fn open_plant_menu(&mut self) {
15411 if self.state.farm_seed_entries().is_empty() {
15412 self.state.push_log("No seeds in inventory to plant");
15413 return;
15414 }
15415 self.state.show_plant_menu = true;
15416 self.state.plant_menu_index = 0;
15417 self.state.plant_quantity = 1;
15418 self.state.clamp_plant_menu();
15419 }
15420
15421 pub fn close_plant_menu(&mut self) {
15422 self.state.show_plant_menu = false;
15423 }
15424
15425 pub fn plant_menu_move(&mut self, delta: i32) {
15426 let n = self.state.farm_seed_entries().len();
15427 if n == 0 {
15428 return;
15429 }
15430 let idx = self.state.plant_menu_index as i32 + delta;
15431 self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
15432 self.state.clamp_plant_menu();
15433 }
15434
15435 pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
15436 let next = self.state.plant_quantity as i32 + delta;
15437 self.state.plant_quantity = next.max(1) as u32;
15438 self.state.clamp_plant_menu();
15439 }
15440
15441 pub fn plant_menu_set_quantity_max(&mut self) {
15442 if let Some((_, max, _)) = self.state.plant_menu_selection() {
15443 self.state.plant_quantity = max;
15444 }
15445 self.state.clamp_plant_menu();
15446 }
15447
15448 pub fn plant_menu_set_quantity_min(&mut self) {
15449 self.state.plant_quantity = 1;
15450 self.state.clamp_plant_menu();
15451 }
15452
15453 pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
15454 let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
15455 self.close_plant_menu();
15456 anyhow::bail!("no seeds to plant");
15457 };
15458 self.close_plant_menu();
15459 self.plant_seeds(seed, qty).await?;
15460 self.state.push_log(format!("Planted {qty}× {label}"));
15461 Ok(())
15462 }
15463
15464 pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
15467 if !self.state.is_alive() {
15468 anyhow::bail!("you are dead");
15469 }
15470 let binding = self
15471 .state
15472 .hotbar_ability(slot)
15473 .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
15474 .to_string();
15475 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
15476 let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
15477 if qty == 0 {
15478 anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
15479 }
15480 return self.use_item(template_id).await;
15481 }
15482 let ability_id = binding;
15483 if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
15484 return self
15485 .cast_ability(&ability_id, Some(self.state.entity_id))
15486 .await;
15487 }
15488 let is_heal = ability_id == "heal_touch"
15489 || self
15490 .state
15491 .ability_meta
15492 .get(&ability_id)
15493 .map(|meta| meta.is_heal)
15494 .unwrap_or(false);
15495 let target = if is_heal {
15496 Some(
15497 self.state
15498 .target_for_slot(2)
15499 .unwrap_or(self.state.entity_id),
15500 )
15501 } else {
15502 self.state
15503 .target_for_slot(1)
15504 .or_else(|| self.state.target_for_slot(2))
15505 };
15506 let Some(target_id) = target else {
15507 anyhow::bail!("no target — Tab to select, then press the hotbar key");
15508 };
15509 self.cast_ability(&ability_id, Some(target_id)).await
15510 }
15511
15512 pub async fn set_hotbar_slot(
15515 &mut self,
15516 slot: u8,
15517 ability_id: Option<&str>,
15518 ) -> anyhow::Result<()> {
15519 if !self.state.is_alive() {
15520 anyhow::bail!("you are dead");
15521 }
15522 if !(1..=9).contains(&slot) {
15523 anyhow::bail!("hotbar slot must be 1–9");
15524 }
15525 let ability_id = ability_id
15526 .map(str::trim)
15527 .filter(|id| !id.is_empty())
15528 .map(str::to_string);
15529 self.seq += 1;
15530 self.session
15531 .submit_intent(Intent::SetHotbarSlot {
15532 entity_id: self.state.entity_id,
15533 slot,
15534 ability_id: ability_id.clone(),
15535 seq: self.seq,
15536 })
15537 .await?;
15538 self.state.intents_sent += 1;
15539 let idx = (slot - 1) as usize;
15540 if self.state.hotbar.len() < 9 {
15541 self.state.hotbar.resize(9, None);
15542 }
15543 if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
15544 *slot_mut = ability_id.clone();
15545 }
15546 match ability_id {
15547 Some(id) => {
15548 let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
15549 format!("use {tid}")
15550 } else {
15551 id
15552 };
15553 self.state.push_log(format!("Hotbar {slot} → {label}"))
15554 }
15555 None => self.state.push_log(format!("Hotbar {slot} cleared")),
15556 }
15557 Ok(())
15558 }
15559
15560 pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
15561 self.state.npc_verb_options()
15562 }
15563
15564 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
15565 let Some(npc_id) = self.state.npc_verb_target.clone() else {
15566 return Ok(());
15567 };
15568 let options = self.npc_verb_options();
15569 let choice = options
15570 .get(self.state.npc_verb_index)
15571 .cloned()
15572 .unwrap_or_else(GameState::talk_choice);
15573 match choice.action {
15574 NpcVerbAction::QuestGive { quest_id } => {
15575 self.submit_npc_quest_turn_in(&npc_id, Some(&quest_id))
15576 .await?;
15577 self.state.show_npc_verb_menu = false;
15578 }
15579 NpcVerbAction::Talk => {
15580 self.open_npc_talk(&npc_id, None).await?;
15581 }
15582 NpcVerbAction::QuestTalk { quest_id } => {
15583 self.open_npc_talk(&npc_id, Some(&quest_id)).await?;
15584 }
15585 NpcVerbAction::Trade | NpcVerbAction::Bank | NpcVerbAction::Storage | NpcVerbAction::Market => {
15586 self.seq += 1;
15587 self.session
15588 .submit_intent(Intent::Interact {
15589 entity_id: self.state.entity_id,
15590 target_id: npc_id,
15591 seq: self.seq,
15592 })
15593 .await?;
15594 self.state.intents_sent += 1;
15595 }
15596 }
15597 Ok(())
15598 }
15599
15600 async fn open_npc_talk(&mut self, npc_id: &str, quest_id: Option<&str>) -> anyhow::Result<()> {
15601 self.seq += 1;
15602 self.session
15603 .submit_intent(Intent::NpcTalkOpen {
15604 entity_id: self.state.entity_id,
15605 npc_id: npc_id.to_string(),
15606 quest_id: quest_id.map(str::to_string),
15607 seq: self.seq,
15608 })
15609 .await?;
15610 self.state.intents_sent += 1;
15611 Ok(())
15612 }
15613
15614 async fn submit_npc_quest_turn_in(
15615 &mut self,
15616 npc_id: &str,
15617 quest_id: Option<&str>,
15618 ) -> anyhow::Result<()> {
15619 let catalog_ref = self.state.npc_quest_catalog_ref(npc_id);
15620 let pending: Vec<(String, u32, String)> = self
15621 .state
15622 .quest_log
15623 .iter()
15624 .filter(|q| {
15625 q.status == flatland_protocol::QuestStatusView::Active
15626 && quest_id.is_none_or(|id| q.quest_id == id)
15627 })
15628 .flat_map(|q| q.objectives.iter())
15629 .filter(|o| {
15630 !o.done
15631 && o.kind == "give_item"
15632 && o.npc_ref.as_deref() == Some(catalog_ref.as_str())
15633 })
15634 .filter_map(|o| {
15635 let template = o.item_template.clone()?;
15636 let remaining = o.required.saturating_sub(o.current);
15637 if remaining == 0 {
15638 return None;
15639 }
15640 Some((template, remaining, o.label.clone()))
15641 })
15642 .collect();
15643 if pending.is_empty() {
15644 self.state.push_log("Nothing to turn in here.");
15645 return Ok(());
15646 }
15647 let mut sent = 0u32;
15648 for (template, remaining, label) in pending {
15649 let held = self.state.count_inventory_template(&template);
15650 let qty = remaining.min(held);
15651 if qty == 0 {
15652 self.state.push_log(format!("Need {label}"));
15653 continue;
15654 }
15655 self.seq += 1;
15656 self.session
15657 .submit_intent(Intent::QuestGiveItem {
15658 entity_id: self.state.entity_id,
15659 npc_id: npc_id.to_string(),
15660 template_id: template,
15661 quantity: qty,
15662 seq: self.seq,
15663 })
15664 .await?;
15665 self.state.intents_sent += 1;
15666 sent += 1;
15667 }
15668 if sent > 0 {
15669 self.state.push_log("Turning in quest items.");
15670 }
15671 Ok(())
15672 }
15673
15674 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
15675 let Some(chat) = self.state.npc_chat.clone() else {
15676 return Ok(());
15677 };
15678 let message = chat.input.trim().to_string();
15679 if message.is_empty() || chat.pending {
15680 return Ok(());
15681 }
15682 if let Some(c) = self.state.npc_chat.as_mut() {
15683 c.lines.push(format!("You: {message}"));
15684 c.input.clear();
15685 c.pending = true;
15686 }
15687 self.seq += 1;
15688 self.session
15689 .submit_intent(Intent::NpcTalkSay {
15690 entity_id: self.state.entity_id,
15691 npc_id: chat.npc_id,
15692 message,
15693 seq: self.seq,
15694 })
15695 .await?;
15696 self.state.intents_sent += 1;
15697 Ok(())
15698 }
15699
15700 pub async fn npc_talk_topic(&mut self, index: usize) -> anyhow::Result<()> {
15701 let topic = self
15702 .state
15703 .npc_chat
15704 .as_ref()
15705 .and_then(|c| c.suggested_topics.get(index))
15706 .cloned();
15707 let Some(topic) = topic else {
15708 return Ok(());
15709 };
15710 if let Some(c) = self.state.npc_chat.as_mut() {
15711 if c.pending {
15712 return Ok(());
15713 }
15714 c.input = topic;
15715 }
15716 self.npc_talk_send().await
15717 }
15718
15719 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
15720 let return_to_verbs = self.state.npc_verb_target.is_some();
15721 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
15722 self.state.show_npc_chat = false;
15723 if return_to_verbs {
15724 self.state.show_npc_verb_menu = true;
15725 }
15726 return Ok(());
15727 };
15728 self.seq += 1;
15729 self.session
15730 .submit_intent(Intent::NpcTalkClose {
15731 entity_id: self.state.entity_id,
15732 npc_id,
15733 seq: self.seq,
15734 })
15735 .await?;
15736 self.state.intents_sent += 1;
15737 self.state.show_npc_chat = false;
15738 self.state.npc_chat = None;
15739 if return_to_verbs {
15740 self.state.show_npc_verb_menu = true;
15741 self.state.npc_verb_notice = None;
15742 }
15743 Ok(())
15744 }
15745
15746 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
15748 if self.state.show_quest_offer
15749 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
15750 {
15751 self.quest_offer_decline();
15752 return Ok(());
15753 }
15754 if self.state.show_npc_chat {
15755 return self.npc_talk_close().await;
15756 }
15757 if self.state.show_shop_menu {
15758 return self.back_from_shop_menu().await;
15759 }
15760 if self.state.bank_panel.is_some() {
15761 if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
15762 self.bank_transfer_back();
15763 return Ok(());
15764 }
15765 return self.close_bank_panel().await;
15766 }
15767 if self.state.storage_panel.is_some() {
15768 if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
15769 self.storage_ui_back();
15770 return Ok(());
15771 }
15772 return self.close_storage_panel().await;
15773 }
15774 if self.state.market_panel.is_some() {
15775 if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
15776 self.market_ui_back();
15777 return Ok(());
15778 }
15779 if self.state.market_buy_confirm.is_some() {
15780 self.state.market_buy_confirm = None;
15781 return Ok(());
15782 }
15783 return self.close_market_panel().await;
15784 }
15785 if self.state.show_npc_verb_menu {
15786 self.state.show_npc_verb_menu = false;
15787 self.state.npc_verb_target = None;
15788 }
15789 Ok(())
15790 }
15791
15792 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
15793 self.seq += 1;
15794 self.session
15795 .submit_intent(Intent::TestDamage {
15796 entity_id: self.state.entity_id,
15797 amount,
15798 seq: self.seq,
15799 })
15800 .await?;
15801 self.state.intents_sent += 1;
15802 Ok(())
15803 }
15804
15805 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
15806 self.cycle_combat_target_slot(1, reverse).await
15807 }
15808
15809 pub async fn cycle_combat_target_slot(
15810 &mut self,
15811 slot_index: u8,
15812 reverse: bool,
15813 ) -> anyhow::Result<()> {
15814 if !self.state.is_alive() {
15815 anyhow::bail!("you are dead");
15816 }
15817 let candidates = self.state.candidates_for_slot(slot_index);
15818 if candidates.is_empty() {
15819 anyhow::bail!("no targets nearby");
15820 }
15821 let current = self.state.target_for_slot(slot_index);
15822 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
15823 let next_idx = match idx {
15824 None => 0,
15825 Some(i) if reverse => {
15826 if i == 0 {
15827 candidates.len() - 1
15828 } else {
15829 i - 1
15830 }
15831 }
15832 Some(i) => (i + 1) % candidates.len(),
15833 };
15834 if idx == Some(next_idx) && candidates.len() == 1 {
15835 self.clear_combat_target_slot(slot_index).await?;
15836 return Ok(());
15837 }
15838 let (target_id, label) = candidates[next_idx].clone();
15839 self.set_combat_target_slot(slot_index, target_id, &label)
15840 .await
15841 }
15842
15843 pub async fn set_combat_target_slot(
15844 &mut self,
15845 slot_index: u8,
15846 target_id: EntityId,
15847 label: &str,
15848 ) -> anyhow::Result<()> {
15849 if !self.state.is_alive() {
15850 anyhow::bail!("you are dead");
15851 }
15852 self.seq += 1;
15853 self.session
15854 .submit_intent(Intent::SetTargetSlot {
15855 entity_id: self.state.entity_id,
15856 slot_index,
15857 target_id,
15858 seq: self.seq,
15859 })
15860 .await?;
15861 self.state.intents_sent += 1;
15862 if slot_index == 1 {
15863 self.state.combat_target = Some(target_id);
15864 self.state.combat_target_label = Some(label.to_string());
15865 }
15866 self.state
15867 .push_log(format!("Slot {slot_index} target: {label}"));
15868 Ok(())
15869 }
15870
15871 pub async fn set_combat_target(
15872 &mut self,
15873 target_id: EntityId,
15874 label: &str,
15875 ) -> anyhow::Result<()> {
15876 self.set_combat_target_slot(1, target_id, label).await
15877 }
15878
15879 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
15880 if slot_index == 1 && self.state.combat_target.is_none() {
15881 return Ok(());
15882 }
15883 self.seq += 1;
15884 self.session
15885 .submit_intent(Intent::ClearTargetSlot {
15886 entity_id: self.state.entity_id,
15887 slot_index,
15888 seq: self.seq,
15889 })
15890 .await?;
15891 if slot_index == 1 {
15892 self.state.combat_target = None;
15893 self.state.combat_target_label = None;
15894 }
15895 self.state.intents_sent += 1;
15896 self.state
15897 .push_log(format!("Slot {slot_index} target cleared"));
15898 Ok(())
15899 }
15900
15901 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
15902 self.clear_combat_target_slot(1).await
15903 }
15904
15905 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
15906 if !self.state.is_alive() {
15907 anyhow::bail!("you are dead");
15908 }
15909 self.seq += 1;
15910 self.session
15911 .submit_intent(Intent::AdvanceRotation {
15912 entity_id: self.state.entity_id,
15913 slot_index,
15914 seq: self.seq,
15915 })
15916 .await?;
15917 self.state.intents_sent += 1;
15918 Ok(())
15919 }
15920
15921 pub async fn assign_slot_preset(
15922 &mut self,
15923 slot_index: u8,
15924 preset_id: &str,
15925 ) -> anyhow::Result<()> {
15926 if !self.state.is_alive() {
15927 anyhow::bail!("you are dead");
15928 }
15929 self.seq += 1;
15930 self.session
15931 .submit_intent(Intent::AssignSlotPreset {
15932 entity_id: self.state.entity_id,
15933 slot_index,
15934 preset_id: preset_id.to_string(),
15935 seq: self.seq,
15936 })
15937 .await?;
15938 self.state.intents_sent += 1;
15939 if let Some(slot) = self
15940 .state
15941 .combat_slots
15942 .iter_mut()
15943 .find(|s| s.slot_index == slot_index)
15944 {
15945 slot.preset_id = Some(preset_id.to_string());
15946 if let Some(preset) = self
15947 .state
15948 .rotation_presets
15949 .iter()
15950 .find(|p| p.id == preset_id)
15951 {
15952 slot.preset_label = Some(preset.label.clone());
15953 slot.rotation = preset.abilities.clone();
15954 slot.rotation_index = 0;
15955 }
15956 }
15957 self.state
15958 .push_log(format!("T{slot_index} loadout → {preset_id}"));
15959 Ok(())
15960 }
15961
15962 pub async fn cast_ability(
15963 &mut self,
15964 ability_id: &str,
15965 target_id: Option<EntityId>,
15966 ) -> anyhow::Result<()> {
15967 if !self.state.is_alive() {
15968 anyhow::bail!("you are dead");
15969 }
15970 let allows_ground = self.state.ability_allows_ground(ability_id);
15971 let requires_ground = self.state.ability_requires_ground(ability_id);
15972 if requires_ground && self.state.ground_target.is_none() {
15973 anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
15974 }
15975 let (resolved_target_id, target_point) = if allows_ground {
15976 if let Some((x, y, z)) = self.state.ground_target {
15977 (
15978 target_id.unwrap_or(self.state.entity_id),
15979 Some(flatland_protocol::AimPoint { x, y, z }),
15980 )
15981 } else {
15982 (
15983 target_id
15984 .or_else(|| self.state.target_for_slot(2))
15985 .or_else(|| self.state.target_for_slot(1))
15986 .unwrap_or(self.state.entity_id),
15987 None,
15988 )
15989 }
15990 } else {
15991 (
15992 target_id
15993 .or_else(|| self.state.target_for_slot(2))
15994 .or_else(|| self.state.target_for_slot(1))
15995 .unwrap_or(self.state.entity_id),
15996 None,
15997 )
15998 };
15999 self.seq += 1;
16000 self.session
16001 .submit_intent(Intent::Cast {
16002 entity_id: self.state.entity_id,
16003 ability_id: ability_id.to_string(),
16004 target_id: resolved_target_id,
16005 target_point,
16006 seq: self.seq,
16007 })
16008 .await?;
16009 self.state.intents_sent += 1;
16010 match target_point {
16011 Some(point) => self.state.push_log(format!(
16012 "Cast {ability_id} → ({:.1}, {:.1})",
16013 point.x, point.y
16014 )),
16015 None => self
16016 .state
16017 .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
16018 }
16019 Ok(())
16020 }
16021
16022 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
16023 self.seq += 1;
16024 self.session
16025 .submit_intent(Intent::UpsertRotationPreset {
16026 entity_id: self.state.entity_id,
16027 preset: preset.clone(),
16028 seq: self.seq,
16029 })
16030 .await?;
16031 self.state.intents_sent += 1;
16032 if let Some(existing) = self
16033 .state
16034 .rotation_presets
16035 .iter_mut()
16036 .find(|p| p.id == preset.id)
16037 {
16038 *existing = preset.clone();
16039 } else {
16040 self.state.rotation_presets.push(preset.clone());
16041 }
16042 for slot in &mut self.state.combat_slots {
16043 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
16044 slot.preset_label = Some(preset.label.clone());
16045 slot.rotation = preset.abilities.clone();
16046 }
16047 }
16048 self.state
16049 .push_log(format!("Saved rotation: {}", preset.label));
16050 Ok(())
16051 }
16052
16053 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
16054 self.seq += 1;
16055 self.session
16056 .submit_intent(Intent::DeleteRotationPreset {
16057 entity_id: self.state.entity_id,
16058 preset_id: preset_id.to_string(),
16059 seq: self.seq,
16060 })
16061 .await?;
16062 self.state.intents_sent += 1;
16063 self.state.rotation_presets.retain(|p| p.id != preset_id);
16064 for slot in &mut self.state.combat_slots {
16065 if slot.preset_id.as_deref() == Some(preset_id) {
16066 slot.preset_id = None;
16067 slot.preset_label = None;
16068 slot.rotation.clear();
16069 slot.rotation_index = 0;
16070 }
16071 }
16072 self.state
16073 .push_log(format!("Deleted rotation: {preset_id}"));
16074 Ok(())
16075 }
16076
16077 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
16078 if !self.state.is_alive() {
16079 anyhow::bail!("you are dead");
16080 }
16081 let enabled = !self
16082 .state
16083 .combat_slots
16084 .iter()
16085 .find(|s| s.slot_index == slot_index)
16086 .map(|s| s.auto_enabled)
16087 .unwrap_or(false);
16088 self.seq += 1;
16089 self.session
16090 .submit_intent(Intent::SetAutoAttack {
16091 entity_id: self.state.entity_id,
16092 slot_index,
16093 enabled,
16094 seq: self.seq,
16095 })
16096 .await?;
16097 if slot_index == 1 {
16098 self.state.auto_attack = enabled;
16099 }
16100 self.state.intents_sent += 1;
16101 self.state.push_log(format!(
16102 "T{slot_index} auto {}",
16103 if enabled { "ON" } else { "OFF" }
16104 ));
16105 Ok(())
16106 }
16107
16108 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
16109 if !self.state.connected {
16110 anyhow::bail!("not connected");
16111 }
16112 if !self.state.is_alive() {
16113 anyhow::bail!("you are dead");
16114 }
16115 let (px, py) = self.state.player_position();
16116 if self
16117 .state
16118 .ground_drops
16119 .iter()
16120 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
16121 {
16122 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
16123 }
16124 self.seq += 1;
16125 self.session
16126 .submit_intent(Intent::Pickup {
16127 entity_id: self.state.entity_id,
16128 drop_id: None,
16129 seq: self.seq,
16130 })
16131 .await?;
16132 self.state.intents_sent += 1;
16133 self.state.push_audio(crate::social::AudioCue::LootPickup);
16134 Ok(())
16135 }
16136
16137 pub async fn dodge(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16138 if !self.state.is_alive() {
16139 anyhow::bail!("you are dead");
16140 }
16141 self.seq += 1;
16143 self.session
16144 .submit_intent(Intent::Dodge {
16145 entity_id: self.state.entity_id,
16146 forward,
16147 strafe,
16148 seq: self.seq,
16149 })
16150 .await?;
16151 self.state.intents_sent += 1;
16152 self.state.push_log("Dodge!");
16153 self.state.push_audio(crate::social::AudioCue::CombatDodge);
16154 Ok(())
16155 }
16156
16157 pub async fn lunge(&mut self) -> anyhow::Result<()> {
16158 if !self.state.is_alive() {
16159 anyhow::bail!("you are dead");
16160 }
16161 let (forward, strafe) = self.last_move_axes();
16162 self.seq += 1;
16163 self.session
16164 .submit_intent(Intent::Lunge {
16165 entity_id: self.state.entity_id,
16166 forward,
16167 strafe,
16168 seq: self.seq,
16169 })
16170 .await?;
16171 self.state.intents_sent += 1;
16172 self.state.push_log("Lunge!");
16173 Ok(())
16174 }
16175
16176 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16177 if !self.state.is_alive() {
16178 anyhow::bail!("you are dead");
16179 }
16180 self.seq += 1;
16181 self.session
16182 .submit_intent(Intent::DirectionalJump {
16183 entity_id: self.state.entity_id,
16184 forward,
16185 strafe,
16186 seq: self.seq,
16187 })
16188 .await?;
16189 self.state.intents_sent += 1;
16190 self.state.push_log("Jump!");
16191 Ok(())
16192 }
16193
16194 pub fn last_move_axes(&self) -> (f32, f32) {
16196 (self.last_move_forward, self.last_move_strafe)
16197 }
16198
16199 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
16200 if !self.state.is_alive() {
16201 anyhow::bail!("you are dead");
16202 }
16203 self.seq += 1;
16204 self.session
16205 .submit_intent(Intent::Block {
16206 entity_id: self.state.entity_id,
16207 enabled,
16208 seq: self.seq,
16209 })
16210 .await?;
16211 self.state.intents_sent += 1;
16212 if enabled {
16213 self.state.push_log("Blocking");
16214 self.state.push_audio(crate::social::AudioCue::CombatBlock);
16215 }
16216 Ok(())
16217 }
16218
16219 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
16220 if !self.state.is_alive() {
16221 anyhow::bail!("you are dead");
16222 }
16223 self.seq += 1;
16224 self.session
16225 .submit_intent(Intent::EquipMainhand {
16226 entity_id: self.state.entity_id,
16227 template_id,
16228 instance_id: None,
16229 seq: self.seq,
16230 })
16231 .await?;
16232 self.state.intents_sent += 1;
16233 Ok(())
16234 }
16235
16236 pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
16238 let idx = self.state.equip_menu_index;
16239 let slots = equip_paperdoll_rows(&self.state);
16240 let Some(row) = slots.get(idx) else {
16241 return Ok(());
16242 };
16243 match row {
16244 EquipPaperdollRow::Body { slot, filled } => {
16245 if *filled {
16246 self.equip_worn(*slot, None).await
16247 } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
16248 self.equip_worn(*slot, Some(inst)).await
16249 } else {
16250 self.state
16251 .push_log(format!("No item for {}", body_slot_label(*slot)));
16252 Ok(())
16253 }
16254 }
16255 EquipPaperdollRow::Mainhand { filled } => {
16256 if *filled {
16257 self.unequip_mainhand().await
16258 } else if let Some(tid) = first_inventory_weapon(&self.state) {
16259 self.equip_mainhand(Some(tid)).await
16260 } else {
16261 self.state.push_log("No weapon in inventory".to_string());
16262 Ok(())
16263 }
16264 }
16265 EquipPaperdollRow::Offhand { filled, locked } => {
16266 if *locked {
16267 self.state
16268 .push_log("Offhand locked — two-handed weapon equipped".to_string());
16269 Ok(())
16270 } else if *filled {
16271 self.unequip_offhand().await
16272 } else if let Some(tid) = first_inventory_offhand(&self.state) {
16273 self.equip_offhand(Some(tid)).await
16274 } else {
16275 self.state
16276 .push_log("No offhand item in inventory".to_string());
16277 Ok(())
16278 }
16279 }
16280 }
16281 }
16282
16283 pub async fn say(
16284 &mut self,
16285 channel: flatland_protocol::ChatChannel,
16286 text: &str,
16287 ) -> anyhow::Result<()> {
16288 self.say_to(channel, text, None).await
16289 }
16290
16291 pub async fn say_to(
16292 &mut self,
16293 channel: flatland_protocol::ChatChannel,
16294 text: &str,
16295 to_entity: Option<EntityId>,
16296 ) -> anyhow::Result<()> {
16297 self.seq += 1;
16298 self.session
16299 .submit_intent(Intent::Say {
16300 entity_id: self.state.entity_id,
16301 channel,
16302 text: text.to_string(),
16303 to_entity,
16304 seq: self.seq,
16305 })
16306 .await?;
16307 self.state.intents_sent += 1;
16308 Ok(())
16309 }
16310
16311 pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
16312 let Some(peer) = self.state.player_verbs.target_entity else {
16313 return Ok(());
16314 };
16315 let label = self.state.player_verbs.target_label.clone();
16316 let choice = crate::social::PlayerVerbState::options()
16317 .get(self.state.player_verbs.index)
16318 .copied()
16319 .unwrap_or("Whisper");
16320 self.state.player_verbs.close();
16321 match choice {
16322 "Trade" => {
16323 self.seq += 1;
16326 self.session
16327 .submit_intent(Intent::TradeRequest {
16328 entity_id: self.state.entity_id,
16329 peer_entity_id: peer,
16330 seq: self.seq,
16331 })
16332 .await?;
16333 self.state.intents_sent += 1;
16334 self.state.social_chat.push_system(format!(
16335 "Trade request sent to {label} — waiting for accept"
16336 ));
16337 }
16338 "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
16339 _ => self.state.social_chat.focus_nearby(),
16340 }
16341 Ok(())
16342 }
16343
16344 pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
16345 let Some(pending) = self.state.social_chat.pending_trade.take() else {
16346 return Ok(());
16347 };
16348 self.seq += 1;
16349 self.session
16350 .submit_intent(Intent::TradeRespond {
16351 entity_id: self.state.entity_id,
16352 peer_entity_id: pending.from_entity,
16353 accept,
16354 seq: self.seq,
16355 })
16356 .await?;
16357 self.state.intents_sent += 1;
16358 if accept {
16359 self.state
16360 .social_chat
16361 .push_system(format!("Accepted trade with {}", pending.from_name));
16362 } else {
16363 self.state
16364 .social_chat
16365 .push_system(format!("Declined trade with {}", pending.from_name));
16366 }
16367 Ok(())
16368 }
16369
16370 pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
16371 let text = self.state.social_chat.buffer.trim().to_string();
16372 if text.is_empty() {
16373 return Ok(());
16374 }
16375 self.state.social_chat.buffer.clear();
16376 if crate::social::is_chat_slash_line(&text) {
16377 match crate::social::parse_chat_slash(&text) {
16378 Some(cmd) => return self.apply_chat_slash(cmd).await,
16379 None => {
16380 self.state.social_chat.push_system(format!(
16381 "Unknown command — {}",
16382 crate::social::chat_slash_help_text()
16383 ));
16384 return Ok(());
16385 }
16386 }
16387 }
16388 let thread = self.state.social_chat.thread;
16389 let channel = thread.channel();
16390 let to = thread.to_entity();
16391 if let Some(peer) = to {
16392 let label = self.state.social_chat.peer_label.clone();
16393 self.state
16394 .social_chat
16395 .remember_whisper_peer(peer, &label, channel);
16396 }
16397 self.say_to(channel, &text, to).await
16398 }
16399
16400 async fn apply_chat_slash(
16401 &mut self,
16402 cmd: crate::social::ChatSlashCommand,
16403 ) -> anyhow::Result<()> {
16404 use crate::social::{chat_slash_help_text, ChatSlashCommand};
16405 match cmd {
16406 ChatSlashCommand::Help => {
16407 self.state
16408 .social_chat
16409 .push_system(chat_slash_help_text().to_string());
16410 Ok(())
16411 }
16412 ChatSlashCommand::Nearby { message } => {
16413 self.state.social_chat.focus_nearby();
16414 self.state
16415 .social_chat
16416 .push_system("Nearby speech — everyone close can hear");
16417 if let Some(msg) = message {
16418 self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
16419 .await
16420 } else {
16421 Ok(())
16422 }
16423 }
16424 ChatSlashCommand::Reply { message } => {
16425 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16426 self.state
16427 .social_chat
16428 .push_system("No one to reply to — wait for a whisper, or /whisper Name");
16429 return Ok(());
16430 };
16431 let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
16432 self.state
16433 .social_chat
16434 .set_whisper_thread(peer.entity_id, &peer.label, stone);
16435 self.state.social_chat.push_system(format!(
16436 "Replying to {} — type and Enter · /nearby",
16437 peer.label
16438 ));
16439 if let Some(msg) = message {
16440 self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
16441 } else {
16442 Ok(())
16443 }
16444 }
16445 ChatSlashCommand::Whisper { name, message } => {
16446 let (peer_id, label, stone) = if let Some(name) = name {
16447 match self.resolve_whisper_target(&name) {
16448 Ok(t) => t,
16449 Err(err) => {
16450 self.state.social_chat.push_system(err);
16451 return Ok(());
16452 }
16453 }
16454 } else {
16455 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16456 self.state.social_chat.push_system(
16457 "Usage: /whisper Name [message] · or /reply after someone whispers you",
16458 );
16459 return Ok(());
16460 };
16461 (
16462 peer.entity_id,
16463 peer.label,
16464 peer.channel == flatland_protocol::ChatChannel::WhisperStone,
16465 )
16466 };
16467 self.state
16468 .social_chat
16469 .set_whisper_thread(peer_id, &label, stone);
16470 let channel = if stone {
16471 flatland_protocol::ChatChannel::WhisperStone
16472 } else {
16473 flatland_protocol::ChatChannel::Whisper
16474 };
16475 if let Some(msg) = message {
16476 self.state
16477 .social_chat
16478 .push_system(format!("Whisper → {label}"));
16479 self.say_to(channel, &msg, Some(peer_id)).await
16480 } else {
16481 self.state.social_chat.push_system(format!(
16482 "Whispering {label} — type and Enter · Esc / /nearby cancels"
16483 ));
16484 Ok(())
16485 }
16486 }
16487 }
16488 }
16489
16490 fn resolve_whisper_target(&self, name: &str) -> Result<(EntityId, String, bool), String> {
16492 let needle = name.trim().to_ascii_lowercase();
16493 if needle.is_empty() {
16494 return Err("Usage: /whisper Name [message]".into());
16495 }
16496 let mut candidates: Vec<(EntityId, String)> = self
16497 .state
16498 .entities
16499 .iter()
16500 .filter(|e| e.id != self.state.entity_id)
16501 .filter(|e| !e.label.trim().is_empty())
16502 .filter(|e| e.vitals.is_some())
16503 .filter(|e| !self.state.npcs.iter().any(|n| n.id == e.id.to_string()))
16504 .filter(|e| !self.state.hired_workers.iter().any(|w| w.entity_id == e.id))
16505 .map(|e| (e.id, e.label.clone()))
16506 .collect();
16507
16508 if let Some(last) = &self.state.social_chat.last_whisper_peer {
16510 if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
16511 candidates.push((last.entity_id, last.label.clone()));
16512 }
16513 }
16514
16515 let exact: Vec<_> = candidates
16516 .iter()
16517 .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
16518 .cloned()
16519 .collect();
16520 let pool = if exact.len() == 1 {
16521 exact
16522 } else if exact.len() > 1 {
16523 return Err(format!(
16524 "Several players named '{name}' nearby — move closer and try again"
16525 ));
16526 } else {
16527 let starts: Vec<_> = candidates
16528 .iter()
16529 .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
16530 .cloned()
16531 .collect();
16532 if starts.len() == 1 {
16533 starts
16534 } else if starts.len() > 1 {
16535 let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
16536 return Err(format!(
16537 "Ambiguous name '{name}' — matches: {}",
16538 names.join(", ")
16539 ));
16540 } else {
16541 let contains: Vec<_> = candidates
16542 .iter()
16543 .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
16544 .cloned()
16545 .collect();
16546 if contains.len() == 1 {
16547 contains
16548 } else if contains.is_empty() {
16549 return Err(format!(
16550 "No player matching '{name}' in range — get closer or check the spelling"
16551 ));
16552 } else {
16553 let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
16554 return Err(format!(
16555 "Ambiguous name '{name}' — matches: {}",
16556 names.join(", ")
16557 ));
16558 }
16559 }
16560 };
16561
16562 let (id, label) = pool.into_iter().next().unwrap();
16563 let stone = self
16564 .state
16565 .social_chat
16566 .last_whisper_peer
16567 .as_ref()
16568 .is_some_and(|p| {
16569 p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone
16570 });
16571 Ok((id, label, stone))
16572 }
16573
16574 pub async fn trade_present_selected(
16575 &mut self,
16576 item_instance_id: uuid::Uuid,
16577 ) -> anyhow::Result<()> {
16578 self.trade_present_quantity(item_instance_id, None).await
16579 }
16580
16581 pub async fn trade_present_quantity(
16582 &mut self,
16583 item_instance_id: uuid::Uuid,
16584 quantity: Option<u32>,
16585 ) -> anyhow::Result<()> {
16586 self.seq += 1;
16587 self.session
16588 .submit_intent(Intent::TradePresent {
16589 entity_id: self.state.entity_id,
16590 item_instance_id,
16591 quantity,
16592 seq: self.seq,
16593 })
16594 .await?;
16595 self.state.intents_sent += 1;
16596 self.state.trade_ui.qty_entry = None;
16597 self.state.trade_ui.picking_inventory = false;
16598 Ok(())
16599 }
16600
16601 pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
16603 if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
16604 let qty = self.state.trade_ui.present_quantity();
16605 return self
16606 .trade_present_quantity(entry.item_instance_id, qty)
16607 .await;
16608 }
16609 if !self.state.trade_ui.picking_inventory {
16610 return Ok(());
16611 }
16612 let stacks = self.state.trade_presentable_stacks();
16613 let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
16614 return Ok(());
16615 };
16616 let Some(id) = stack.item_instance_id else {
16617 return Ok(());
16618 };
16619 let label = stack
16620 .display_name
16621 .clone()
16622 .unwrap_or_else(|| stack.template_id.clone());
16623 if stack.quantity <= 1 {
16624 self.trade_present_quantity(id, Some(1)).await
16625 } else {
16626 self.state
16627 .trade_ui
16628 .begin_qty_entry(id, label, stack.quantity);
16629 Ok(())
16630 }
16631 }
16632
16633 pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
16634 self.seq += 1;
16635 self.session
16636 .submit_intent(Intent::TradeSetReady {
16637 entity_id: self.state.entity_id,
16638 ready,
16639 seq: self.seq,
16640 })
16641 .await?;
16642 self.state.intents_sent += 1;
16643 Ok(())
16644 }
16645
16646 pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
16647 self.seq += 1;
16648 self.session
16649 .submit_intent(Intent::TradeCancel {
16650 entity_id: self.state.entity_id,
16651 seq: self.seq,
16652 })
16653 .await?;
16654 self.state.intents_sent += 1;
16655 self.state.trade_ui.close();
16656 Ok(())
16657 }
16658
16659 pub async fn destroy_whisper_stone(
16660 &mut self,
16661 item_instance_id: uuid::Uuid,
16662 ) -> anyhow::Result<()> {
16663 self.seq += 1;
16664 self.session
16665 .submit_intent(Intent::DestroyWhisperStone {
16666 entity_id: self.state.entity_id,
16667 item_instance_id,
16668 seq: self.seq,
16669 })
16670 .await?;
16671 self.state.intents_sent += 1;
16672 Ok(())
16673 }
16674
16675 pub async fn stop(&mut self) -> anyhow::Result<()> {
16676 self.seq += 1;
16677 self.session
16678 .submit_intent(Intent::Stop {
16679 entity_id: self.state.entity_id,
16680 seq: self.seq,
16681 })
16682 .await?;
16683 self.state.intents_sent += 1;
16684 Ok(())
16685 }
16686
16687 pub fn disconnect(&self) {
16688 self.session.disconnect();
16689 }
16690}
16691
16692fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
16693 let dx = ax - bx;
16694 let dy = ay - by;
16695 (dx * dx + dy * dy).sqrt()
16696}
16697
16698#[cfg(test)]
16699mod tests {
16700 use std::collections::BTreeMap;
16701
16702 use super::*;
16703 use flatland_protocol::{
16704 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
16705 };
16706
16707 fn sample_state() -> GameState {
16708 let mut state = GameState {
16709 session_id: 1,
16710 entity_id: 1,
16711 character_id: None,
16712 tick: 0,
16713 chunk_rev: 0,
16714 content_rev: 0,
16715 publish_rev: 0,
16716 entities: vec![EntityState {
16717 id: 1,
16718 label: "You".into(),
16719 transform: Transform {
16720 position: WorldCoord::surface(128.0, 128.0),
16721 yaw: 0.0,
16722 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
16723 },
16724 vitals: None,
16725 attributes: None,
16726 skills: None,
16727 inside_building: None,
16728 tile_id: None,
16729 paperdoll_ref: None,
16730 draw_scale: 1.0,
16731 presentation_state: None,
16732 sprite_mode: None,
16733 progression_xp: None,
16734 combat_cues: vec![],
16735 statuses: vec![],
16736 }],
16737 player: None,
16738 resource_nodes: vec![ResourceNodeView {
16739 id: "oak-1".into(),
16740 label: "Oak".into(),
16741 x: 130.0,
16742 y: 128.0,
16743 z: 0.0,
16744 item_template: "oak_log".into(),
16745 state: ResourceNodeState::Available,
16746 blocking: true,
16747 blocking_radius_m: 0.8,
16748 harvest_off: false,
16749 tile_id: None,
16750 yaw: 0.0,
16751 pitch: 0.0,
16752 roll: 0.0,
16753 draw_scale: 1.0,
16754 sprite_mode: None,
16755 growth_progress: None,
16756 presentation_state: None,
16757 channel_start_tick: None,
16758 channel_end_tick: None,
16759 harvest_drop_templates: vec![],
16760 }],
16761 ground_drops: vec![],
16762 placed_containers: vec![],
16763 buildings: vec![BuildingView {
16764 id: "broker-hut".into(),
16765 label: "Broker".into(),
16766 x: 148.0,
16767 y: 118.0,
16768 width_m: 8.0,
16769 depth_m: 6.0,
16770 interior_blueprint: Some("broker_hut".into()),
16771 tags: vec![],
16772 market_boundary_zone_ids: vec![],
16773 market_max_volume: None,
16774 wall_set: None,
16775 roof_set: None,
16776 }],
16777 doors: vec![flatland_protocol::DoorView {
16778 id: "door-1".into(),
16779 building_id: "broker-hut".into(),
16780 x: 148.0,
16781 y: 118.0,
16782 open: false,
16783 portal: Some("front".into()),
16784 locked: false,
16785 accessible: true,
16786 lock_id: None,
16787 }],
16788 interior_map: None,
16789 npcs: vec![],
16790 blueprints: vec![],
16791 building_materials: vec![],
16792 world_x0: 0.0,
16793 world_y0: 0.0,
16794 world_width_m: 256.0,
16795 world_height_m: 256.0,
16796 terrain_zones: Vec::new(),
16797 z_platforms: Vec::new(),
16798 z_transitions: Vec::new(),
16799 z_bands_outdoor_backup: None,
16800 world_clock: flatland_protocol::WorldClock::default(),
16801 inventory: std::collections::HashMap::new(),
16802 inventory_hints: std::collections::HashMap::new(),
16803 item_catalog: std::collections::HashMap::new(),
16804 logs: VecDeque::new(),
16805 intents_sent: 0,
16806 ticks_received: 0,
16807 connected: true,
16808 disconnect_reason: None,
16809 show_stats: false,
16810 hud_log_hidden: false,
16811 show_equip_menu: false,
16812 equip_menu_index: 0,
16813 show_craft_menu: false,
16814 show_plot_build_menu: false,
16815 plot_build_focus_wall: true,
16816 plot_build_wall_index: 0,
16817 plot_build_roof_index: 0,
16818 craft_menu_index: 0,
16819 craft_batch_quantity: 1,
16820 craft_tab: CraftTab::Ready,
16821 craft_filter: String::new(),
16822 craft_filter_focused: false,
16823 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
16824 show_shop_menu: false,
16825 shop_catalog: None,
16826 bank_panel: None,
16827 bank_menu_index: 0,
16828 bank_ui_mode: BankUiMode::Menu,
16829 storage_panel: None,
16830 market_panel: None,
16831 market_menu_index: 0,
16832 market_filter: String::new(),
16833 market_filter_focused: false,
16834 market_category_filter: None,
16835 market_buy_confirm: None,
16836 market_ui_mode: MarketUiMode::Browse,
16837 storage_menu_index: 0,
16838 storage_ui_mode: StorageUiMode::Menu,
16839 shop_tab: ShopTab::default(),
16840 shop_menu_index: 0,
16841 shop_quantity: 1,
16842 shop_trade_log: VecDeque::new(),
16843 show_npc_verb_menu: false,
16844 npc_verb_target: None,
16845 npc_verb_index: 0,
16846 npc_verb_notice: None,
16847 player_verbs: crate::social::PlayerVerbState::default(),
16848 social_chat: crate::social::SocialChatState::default(),
16849 trade_ui: crate::social::TradeUiState::default(),
16850 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
16851 show_npc_chat: false,
16852 npc_chat: None,
16853 show_inventory_menu: false,
16854 inventory_menu_index: 0,
16855 inventory_tab: InventoryTab::OnPerson,
16856 inventory_filter: String::new(),
16857 inventory_filter_focused: false,
16858 show_move_picker: false,
16859 show_rename_prompt: false,
16860 rename_plot_id: None,
16861 highlighted_plot_id: None,
16862 show_worker_rename: false,
16863 rename_buffer: String::new(),
16864 move_picker_index: 0,
16865 move_picker: None,
16866 show_grant_picker: false,
16867 grant_picker_index: 0,
16868 grant_picker: None,
16869 show_destroy_picker: false,
16870 destroy_confirm_pending: false,
16871 destroy_picker: None,
16872 combat_target: None,
16873 combat_target_label: None,
16874 ground_target: None,
16875 combat_fx: Vec::new(),
16876 ground_hazards: Vec::new(),
16877 property_zones: Vec::new(),
16878 tax_zones: Vec::new(),
16879 growth_zones: Vec::new(),
16880 biome_zones: Vec::new(),
16881 terrain_kind_nav: Vec::new(),
16882 property_plots: Vec::new(),
16883 property_plot_settings: None,
16884 claim_mode: None,
16885 relocate_mode: None,
16886 sell_plot_confirm: None,
16887 sell_plot_armed_at: None,
16888 show_plant_menu: false,
16889 plant_menu_index: 0,
16890 show_farm_access: false,
16891 farm_access_name_draft: String::new(),
16892 farm_access_discount_bps: 0,
16893 farm_access_index: 0,
16894 plant_quantity: 1,
16895 in_combat: false,
16896 auto_attack: true,
16897 combat_has_los: false,
16898 attack_cd_ticks: 0,
16899 gcd_ticks: 0,
16900 weapon_ability_id: "unarmed".into(),
16901 mainhand_template_id: None,
16902 mainhand_label: None,
16903 mainhand_instance_id: None,
16904 offhand_template_id: None,
16905 offhand_label: None,
16906 offhand_instance_id: None,
16907 mainhand_hand_slots: 1,
16908 defense: None,
16909 worn: BTreeMap::new(),
16910 carry_mass: 0.0,
16911 carry_mass_max: 0.0,
16912 encumbrance: flatland_protocol::EncumbranceState::Light,
16913 move_speed_mps: 0.0,
16914 move_speed_mult: 0.0,
16915 inventory_stacks: Vec::new(),
16916 keychain_stacks: Vec::new(),
16917 whisper_pouch_stacks: Vec::new(),
16918 combat_target_detail: None,
16919 statuses: Vec::new(),
16920 cast_progress: None,
16921 timed_channel: None,
16922 plot_build_offer: None,
16923 ability_cooldowns: Vec::new(),
16924 blocking_active: false,
16925 max_target_slots: 1,
16926 combat_slots: Vec::new(),
16927 rotation_presets: Vec::new(),
16928 known_abilities: Vec::new(),
16929 ability_meta: std::collections::HashMap::new(),
16930 ability_mastery: std::collections::HashMap::new(),
16931 hotbar: vec![None; 9],
16932 max_abilities_per_rotation: 0,
16933 show_loadout_menu: false,
16934 show_keychain_menu: false,
16935 keychain_menu_index: 0,
16936 show_rotation_editor: false,
16937 loadout_menu_index: 0,
16938 loadout_hotbar_slot: 1,
16939 loadout_ability_index: 0,
16940 loadout_focus_presets: false,
16941 rotation_editor: RotationEditorState::default(),
16942 harvest_in_progress: false,
16943 harvest_started_at: None,
16944 pending_craft_ack: None,
16945 craft_channel_blueprint_id: None,
16946 pending_worker_job_ack: None,
16947 attending_worker_instance_id: None,
16948 quest_log: Vec::new(),
16949 interactables: Vec::new(),
16950 ledger: None,
16951 career: None,
16952 character_sheet_tab: CharacterSheetTab::Character,
16953 ledger_period: LedgerPeriod::Day,
16954 show_quest_offer: false,
16955 pending_quest_offers: Vec::new(),
16956 quest_offer_index: 0,
16957 show_quest_menu: false,
16958 quest_menu_index: 0,
16959 quest_withdraw_confirm: false,
16960 hired_workers: Vec::new(),
16961 show_workers_menu: false,
16962 workers_menu_index: 0,
16963 worker_dismiss_confirmation: None,
16964 workers_menu_compact: false,
16965 worker_step_display: BTreeMap::new(),
16966 worker_error_display: BTreeMap::new(),
16967 worker_health_ring_until: BTreeMap::new(),
16968 pending_worker_hire_since: None,
16969 show_worker_give_picker: false,
16970 worker_give_picker_index: 0,
16971 worker_give_picker: None,
16972 show_worker_give_target_picker: false,
16973 worker_give_target_picker_index: 0,
16974 worker_give_target_picker: None,
16975 show_worker_take_picker: false,
16976 worker_take_picker_index: 0,
16977 worker_take_picker: None,
16978 show_worker_teach_picker: false,
16979 worker_teach_picker_index: 0,
16980 worker_teach_picker: None,
16981 worker_route_editor: None,
16982 progression_curve: None,
16983 };
16984 state.player = state.entities.first().cloned();
16985 state
16986 }
16987
16988 #[test]
16989 fn template_display_name_uses_item_catalog_for_uuid_ids() {
16990 let mut state = sample_state();
16991 let id = "7ac81ed5-e3d3-4b7f-a7fb-11d6c34a0995";
16992 assert_eq!(state.template_display_name(id), "Unknown item");
16993 state.item_catalog.insert(
16994 id.into(),
16995 ItemCatalogEntryView {
16996 template_id: id.into(),
16997 display_name: "Emerald".into(),
16998 category: "resource".into(),
16999 seed_for: None,
17000 },
17001 );
17002 assert_eq!(state.template_display_name(id), "Emerald");
17003 }
17004
17005 #[test]
17006 fn whisper_cancels_when_peer_walks_out_of_range() {
17007 let mut state = sample_state();
17008 state.player = state.entities.first().cloned();
17009 let mut peer = state.entities[0].clone();
17010 peer.id = 2;
17011 peer.label = "Ada".into();
17012 peer.transform.position = WorldCoord::surface(129.0, 128.0); state.entities.push(peer.clone());
17014 state.social_chat.focus_whisper(2, "Ada");
17015 state.refresh_whisper_range();
17016 assert!(matches!(
17017 state.social_chat.thread,
17018 crate::social::ChatThreadKind::Whisper { peer: 2 }
17019 ));
17020
17021 peer.transform.position = WorldCoord::surface(132.0, 128.0); state.entities[1] = peer;
17023 state.refresh_whisper_range();
17024 assert_eq!(
17025 state.social_chat.thread,
17026 crate::social::ChatThreadKind::Nearby
17027 );
17028 assert!(!state.social_chat.input_focused);
17029 }
17030
17031 #[test]
17032 fn probe_use_world_hired_worker_manage() {
17033 let mut state = sample_state();
17034 state
17035 .hired_workers
17036 .push(flatland_protocol::HiredWorkerView {
17037 instance_id: "worker-1".into(),
17038 entity_id: 42,
17039 def_id: "worker_laborer".into(),
17040 label: "Sam".into(),
17041 x: 129.0,
17042 y: 128.0,
17043 z: 0.0,
17044 mode: flatland_protocol::WorkerModeView::JobLoop,
17045 state: flatland_protocol::WorkerStateView::Working,
17046 step_label: "cultivate".into(),
17047 vitals: flatland_protocol::WorkerVitalsSummary {
17048 health_pct: 100.0,
17049 stamina_pct: 100.0,
17050 mana_pct: 100.0,
17051 hunger_pct: 100.0,
17052 thirst_pct: 100.0,
17053 },
17054 carry_pct: 0.0,
17055 last_error: None,
17056 wage_copper_per_interval: 1,
17057 effective_wage_copper: 1,
17058 wage_meters_walked: 0.0,
17059 lodging_container_id: None,
17060 route: None,
17061 route_stop_index: None,
17062 known_blueprint_ids: Vec::new(),
17063 level: 1,
17064 worker_xp: 0.0,
17065 inventory: Vec::new(),
17066 equipment: flatland_protocol::WorkerEquipmentView::default(),
17067 issue_hint: None,
17068 });
17069 let probe = state.probe_use_world();
17070 let primary = probe.primary.expect("primary");
17071 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17072 assert_eq!(primary.id, "worker-1");
17073 assert!(primary.hint_line().contains("Manage"));
17074 assert!(primary.hint_line().contains("Sam"));
17075 assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17076 }
17077
17078 fn sample_hired_worker(x: f32, y: f32) -> flatland_protocol::HiredWorkerView {
17079 flatland_protocol::HiredWorkerView {
17080 instance_id: "worker-1".into(),
17081 entity_id: 42,
17082 def_id: "worker_laborer".into(),
17083 label: "Sam".into(),
17084 x,
17085 y,
17086 z: 0.0,
17087 mode: flatland_protocol::WorkerModeView::JobLoop,
17088 state: flatland_protocol::WorkerStateView::Working,
17089 step_label: "follow".into(),
17090 vitals: flatland_protocol::WorkerVitalsSummary {
17091 health_pct: 100.0,
17092 stamina_pct: 100.0,
17093 mana_pct: 100.0,
17094 hunger_pct: 100.0,
17095 thirst_pct: 100.0,
17096 },
17097 carry_pct: 0.0,
17098 last_error: None,
17099 wage_copper_per_interval: 1,
17100 effective_wage_copper: 1,
17101 wage_meters_walked: 0.0,
17102 lodging_container_id: None,
17103 route: None,
17104 route_stop_index: None,
17105 known_blueprint_ids: Vec::new(),
17106 level: 1,
17107 worker_xp: 0.0,
17108 inventory: Vec::new(),
17109 equipment: flatland_protocol::WorkerEquipmentView::default(),
17110 issue_hint: None,
17111 }
17112 }
17113
17114 #[test]
17115 fn probe_harvest_beats_closer_hired_worker() {
17116 let mut state = sample_state();
17117 state.resource_nodes[0].x = 129.0;
17118 state.resource_nodes[0].y = 128.0;
17119 state.hired_workers.push(sample_hired_worker(128.2, 128.0));
17120 let probe = state.probe_use_world();
17121 let primary = probe.primary.expect("primary");
17122 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17123 assert_eq!(primary.id, "oak-1");
17124 assert!(state.harvestable_node_in_range());
17125 assert_eq!(
17126 state.nearest_interact_target().as_deref(),
17127 Some("worker-1"),
17128 "harvest is not Interact — worker remains the interact target"
17129 );
17130 }
17131
17132 #[test]
17133 fn probe_door_beats_closer_hired_worker() {
17134 let mut state = sample_state();
17135 state.doors[0].x = 129.2;
17136 state.doors[0].y = 128.0;
17137 state.hired_workers.push(sample_hired_worker(128.3, 128.0));
17138 let probe = state.probe_use_world();
17139 let primary = probe.primary.expect("primary");
17140 assert!(
17141 matches!(
17142 primary.kind,
17143 crate::UseWorldKind::EnterDoor
17144 | crate::UseWorldKind::OpenDoor
17145 | crate::UseWorldKind::CloseDoor
17146 | crate::UseWorldKind::ExitDoor
17147 ),
17148 "door should win over closer worker, got {:?}",
17149 primary.kind
17150 );
17151 assert_eq!(primary.id, "door-1");
17152 assert_eq!(state.nearest_interact_target().as_deref(), Some("door-1"));
17153 }
17154
17155 #[test]
17156 fn probe_worker_when_no_resource_or_door_in_range() {
17157 let mut state = sample_state();
17158 state.hired_workers.push(sample_hired_worker(129.0, 128.0));
17160 let probe = state.probe_use_world();
17161 let primary = probe.primary.expect("primary");
17162 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17163 assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17164 assert!(!state.harvestable_node_in_range());
17165 }
17166
17167 #[test]
17168 fn market_clerk_verb_options_include_market() {
17169 let mut state = sample_state();
17170 state.npcs.push(flatland_protocol::NpcView {
17171 id: "mira_market".into(),
17172 label: "Mira".into(),
17173 role: "market_clerk".into(),
17174 x: 129.0,
17175 y: 128.0,
17176 building_id: Some("town_market".into()),
17177 entity_id: None,
17178 life_state: None,
17179 hp_pct: None,
17180 can_trade: false,
17181 buy_templates: vec![],
17182 tile_id: None,
17183 behavior_state: None,
17184 presentation_state: None,
17185 sprite_mode: None,
17186 paperdoll_ref: None,
17187 draw_scale: 1.0,
17188 yaw: None,
17189 perception_fov_deg: None,
17190 perception_sight_m: None,
17191 perception_hear_m: None,
17192 quest_verbs: Vec::new(),
17193 });
17194 state.npc_verb_target = Some("mira_market".into());
17195 assert_eq!(
17196 state
17197 .npc_verb_options()
17198 .iter()
17199 .map(|v| v.label.as_str())
17200 .collect::<Vec<_>>(),
17201 vec!["Market", "Talk"]
17202 );
17203 }
17204
17205 #[test]
17206 fn butcher_verb_options_include_turn_in_for_give_item() {
17207 let mut state = sample_state();
17208 state.npcs.push(flatland_protocol::NpcView {
17209 id: "town_butcher_1".into(),
17210 label: "Brutus".into(),
17211 role: "butcher".into(),
17212 x: 129.0,
17213 y: 128.0,
17214 building_id: None,
17215 entity_id: None,
17216 life_state: None,
17217 hp_pct: None,
17218 can_trade: true,
17219 buy_templates: vec!["raw_venison".into()],
17220 tile_id: None,
17221 behavior_state: None,
17222 presentation_state: None,
17223 sprite_mode: None,
17224 paperdoll_ref: None,
17225 draw_scale: 1.0,
17226 yaw: None,
17227 perception_fov_deg: None,
17228 perception_sight_m: None,
17229 perception_hear_m: None,
17230 quest_verbs: Vec::new(),
17231 });
17232 state.quest_log.push(flatland_protocol::QuestLogEntry {
17233 quest_id: "deer_threat".into(),
17234 title: "Deer threat".into(),
17235 description: String::new(),
17236 status: flatland_protocol::QuestStatusView::Active,
17237 current_step_id: Some("deliver".into()),
17238 current_step_title: "Deliver venison".into(),
17239 current_step_index: 0,
17240 objectives: vec![flatland_protocol::QuestObjectiveProgress {
17241 label: "Give 3 Raw venison to Brutus".into(),
17242 current: 0,
17243 required: 3,
17244 done: false,
17245 kind: "give_item".into(),
17246 npc_ref: Some("town_butcher_1".into()),
17247 item_template: Some("raw_venison".into()),
17248 blueprint_id: None,
17249 building_id: None,
17250 }],
17251 current_step_reward: flatland_protocol::QuestRewardView::default(),
17252 completion_reward: flatland_protocol::QuestRewardView::default(),
17253 steps: Vec::new(),
17254 is_tracked: true,
17255 can_withdraw: true,
17256 });
17257 state.npc_verb_target = Some("town_butcher_1".into());
17258 assert_eq!(
17259 state
17260 .npc_verb_options()
17261 .iter()
17262 .map(|v| v.label.as_str())
17263 .collect::<Vec<_>>(),
17264 vec!["Turn in: Deer threat", "Talk", "Trade"]
17265 );
17266 }
17267
17268 #[test]
17269 fn ada_verb_options_include_quest_offer() {
17270 let mut state = sample_state();
17271 state.npcs.push(flatland_protocol::NpcView {
17272 id: "ada_broker".into(),
17273 label: "Ada".into(),
17274 role: "broker".into(),
17275 x: 129.0,
17276 y: 128.0,
17277 building_id: None,
17278 entity_id: None,
17279 life_state: None,
17280 hp_pct: None,
17281 can_trade: true,
17282 buy_templates: vec![],
17283 tile_id: None,
17284 behavior_state: None,
17285 presentation_state: None,
17286 sprite_mode: None,
17287 paperdoll_ref: Some("ada_broker".into()),
17288 draw_scale: 1.0,
17289 yaw: None,
17290 perception_fov_deg: None,
17291 perception_sight_m: None,
17292 perception_hear_m: None,
17293 quest_verbs: vec![flatland_protocol::NpcQuestVerb {
17294 quest_id: "ada_goblin_hunt".into(),
17295 label: "Ask about goblins".into(),
17296 kind: flatland_protocol::NpcQuestVerb::KIND_OFFER.into(),
17297 }],
17298 });
17299 state.npc_verb_target = Some("ada_broker".into());
17300 assert_eq!(
17301 state
17302 .npc_verb_options()
17303 .iter()
17304 .map(|v| v.label.as_str())
17305 .collect::<Vec<_>>(),
17306 vec!["Ask about goblins", "Talk", "Trade"]
17307 );
17308 }
17309
17310 #[test]
17311 fn market_list_excludes_currency_stacks() {
17312 let mut state = sample_state();
17313 state.inventory_stacks = vec![
17314 flatland_protocol::ItemStack {
17315 template_id: "copper_coin".into(),
17316 quantity: 50,
17317 item_instance_id: Some(uuid::Uuid::from_u128(10)),
17318 display_name: Some("Copper Coin".into()),
17319 ..Default::default()
17320 },
17321 flatland_protocol::ItemStack {
17322 template_id: "oak_log".into(),
17323 quantity: 2,
17324 item_instance_id: Some(uuid::Uuid::from_u128(11)),
17325 display_name: Some("Oak Log".into()),
17326 ..Default::default()
17327 },
17328 flatland_protocol::ItemStack {
17329 template_id: "whisper_stone".into(),
17330 quantity: 1,
17331 item_instance_id: Some(uuid::Uuid::from_u128(12)),
17332 display_name: Some("Whisper Stone".into()),
17333 category: Some("quest".into()),
17334 listable: Some(false),
17335 ..Default::default()
17336 },
17337 ];
17338 let opts = state.market_list_item_options(&MarketListSourceKind::Person);
17339 assert_eq!(opts.len(), 1);
17340 assert!(opts[0].label.contains("Oak"));
17341 }
17342
17343 #[test]
17344 fn market_browse_filters_by_category_and_search() {
17345 let mut state = sample_state();
17346 state.market_panel = Some(flatland_protocol::MarketPanel {
17347 npc_id: "mira_market".into(),
17348 npc_label: "Mira".into(),
17349 building_id: "town_market".into(),
17350 building_label: "Town Market".into(),
17351 used_volume: 0.0,
17352 max_volume: 100.0,
17353 listings: vec![
17354 flatland_protocol::MarketListingView {
17355 listing_id: uuid::Uuid::from_u128(1),
17356 seller_character_id: uuid::Uuid::from_u128(2),
17357 seller_label: "Ada".into(),
17358 hall_building_id: "town_market".into(),
17359 hall_label: "Town Market".into(),
17360 template_id: "oak_log".into(),
17361 display_name: "Oak Log".into(),
17362 category: "resource".into(),
17363 quantity: 3,
17364 unit_price_copper: 10,
17365 line_total_copper: 30,
17366 npc_price: false,
17367 npc_dump_unit_copper: None,
17368 mine: false,
17369 },
17370 flatland_protocol::MarketListingView {
17371 listing_id: uuid::Uuid::from_u128(3),
17372 seller_character_id: uuid::Uuid::from_u128(2),
17373 seller_label: "Ada".into(),
17374 hall_building_id: "town_market".into(),
17375 hall_label: "Town Market".into(),
17376 template_id: "short_sword".into(),
17377 display_name: "Short Sword".into(),
17378 category: "weapon".into(),
17379 quantity: 1,
17380 unit_price_copper: 100,
17381 line_total_copper: 100,
17382 npc_price: false,
17383 npc_dump_unit_copper: None,
17384 mine: false,
17385 },
17386 ],
17387 tax_bps: 0,
17388 tax_flat_copper: 0,
17389 list_vaults: vec![],
17390 });
17391 assert_eq!(state.market_filtered_listing_indices().len(), 2);
17392 state.market_category_filter = Some("Weapons");
17393 let weapons = state.market_filtered_listing_indices();
17394 assert_eq!(weapons.len(), 1);
17395 assert_eq!(
17396 state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
17397 "Short Sword"
17398 );
17399 state.market_category_filter = None;
17400 state.market_filter = "oak".into();
17401 let oak = state.market_filtered_listing_indices();
17402 assert_eq!(oak.len(), 1);
17403 assert_eq!(
17404 state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
17405 "Oak Log"
17406 );
17407 }
17408
17409 #[test]
17410 fn market_list_source_includes_person_and_vaults() {
17411 let mut state = sample_state();
17412 let item_id = uuid::Uuid::from_u128(1);
17413 state.inventory_stacks = vec![flatland_protocol::ItemStack {
17414 template_id: "oak_log".into(),
17415 quantity: 2,
17416 item_instance_id: Some(item_id),
17417 display_name: Some("Oak Log".into()),
17418 ..Default::default()
17419 }];
17420 state.market_panel = Some(flatland_protocol::MarketPanel {
17421 npc_id: "mira_market".into(),
17422 npc_label: "Mira".into(),
17423 building_id: "town_market".into(),
17424 building_label: "Town Market".into(),
17425 used_volume: 0.0,
17426 max_volume: 100.0,
17427 listings: vec![],
17428 tax_bps: 0,
17429 tax_flat_copper: 0,
17430 list_vaults: vec![flatland_protocol::MarketListVault {
17431 building_id: "town_storage".into(),
17432 building_label: "Town Storage".into(),
17433 contents: vec![flatland_protocol::ItemStack {
17434 template_id: "lumber".into(),
17435 quantity: 1,
17436 item_instance_id: Some(uuid::Uuid::from_u128(2)),
17437 display_name: Some("Lumber".into()),
17438 ..Default::default()
17439 }],
17440 }],
17441 });
17442 let sources = state.market_list_source_options();
17443 assert_eq!(sources.len(), 2);
17444 assert!(matches!(sources[0].0, MarketListSourceKind::Person));
17445 assert!(matches!(
17446 sources[1].0,
17447 MarketListSourceKind::TownStorage { .. }
17448 ));
17449 assert!(sources[1].1.contains("Town Storage"));
17450 }
17451
17452 #[test]
17453 fn npc_market_dump_estimate_from_town_storage_vault() {
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 tax_bps: 0,
17464 tax_flat_copper: 0,
17465 list_vaults: vec![flatland_protocol::MarketListVault {
17466 building_id: "town_storage".into(),
17467 building_label: "Town Storage".into(),
17468 contents: vec![flatland_protocol::ItemStack {
17469 template_id: "lumber".into(),
17470 quantity: 3,
17471 item_instance_id: Some(uuid::Uuid::from_u128(2)),
17472 display_name: Some("Lumber".into()),
17473 base_value_copper: Some(20),
17474 ..Default::default()
17475 }],
17476 }],
17477 });
17478 assert_eq!(
17479 state.npc_market_dump_unit_estimate("lumber"),
17480 Some(9),
17481 "vault stack base_value should enable NPC price estimate"
17482 );
17483 }
17484
17485 #[test]
17486 fn probe_use_world_npc_beats_nearby_loot() {
17487 let mut state = sample_state();
17488 state.npcs.push(flatland_protocol::NpcView {
17489 id: "ada".into(),
17490 label: "Ada".into(),
17491 role: "broker".into(),
17492 x: 129.0,
17493 y: 128.0,
17494 building_id: None,
17495 entity_id: None,
17496 life_state: None,
17497 hp_pct: None,
17498 can_trade: true,
17499 buy_templates: vec!["lumber".into()],
17500 tile_id: None,
17501 behavior_state: None,
17502 presentation_state: None,
17503 sprite_mode: None,
17504 paperdoll_ref: None,
17505 draw_scale: 1.0,
17506 yaw: None,
17507 perception_fov_deg: None,
17508 perception_sight_m: None,
17509 perception_hear_m: None,
17510 quest_verbs: Vec::new(),
17511 });
17512 state.ground_drops.push(flatland_protocol::GroundDropView {
17513 id: "d1".into(),
17514 template_id: "lumber".into(),
17515 quantity: 1,
17516 x: 128.5,
17517 y: 128.0,
17518 z: 0.0,
17519 tile_id: None,
17520 display_name: None,
17521 yaw: 0.0,
17522 pitch: 0.0,
17523 roll: 0.0,
17524 draw_scale: 1.0,
17525 item_instance_id: None,
17526 props: Default::default(),
17527 status_bindings: Vec::new(),
17528 });
17529 let probe = state.probe_use_world();
17530 let primary = probe.primary.expect("primary");
17531 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
17532 assert_eq!(primary.id, "ada");
17533 }
17534
17535 #[test]
17536 fn probe_use_world_harvest_when_in_range() {
17537 let state = sample_state(); let probe = state.probe_use_world();
17539 assert!(
17540 probe.primary.is_none(),
17541 "oak is 2m away, out of harvest range"
17542 );
17543 assert!(probe
17544 .candidates
17545 .iter()
17546 .any(|c| c.kind == crate::UseWorldKind::Harvest));
17547
17548 let mut state = sample_state();
17549 state.resource_nodes[0].x = 129.0;
17550 let probe = state.probe_use_world();
17551 let primary = probe.primary.expect("primary");
17552 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17553 }
17554
17555 #[test]
17556 fn probe_use_world_door_uses_building_label() {
17557 let mut state = sample_state();
17558 state.doors[0].x = 129.0;
17559 state.doors[0].y = 128.0;
17560 let probe = state.probe_use_world();
17561 let primary = probe.primary.expect("primary");
17562 assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
17563 assert_eq!(primary.label, "Broker");
17564 assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
17565 }
17566
17567 #[test]
17568 fn empty_entity_tick_preserves_welcome_snapshot() {
17569 let mut state = sample_state();
17570 state.inventory.insert("carrot".into(), 3);
17571 let delta = TickDelta {
17572 tick: 1,
17573 entities: vec![],
17574 resource_nodes: vec![],
17575 ground_drops: vec![],
17576 placed_containers: vec![],
17577 buildings: vec![],
17578 doors: vec![],
17579 interior_map: None,
17580 npcs: vec![],
17581 inventory: vec![],
17582 blueprints: vec![],
17583 building_materials: vec![],
17584 world_clock: flatland_protocol::WorldClock::default(),
17585 combat: None,
17586 quest_log: vec![],
17587 hired_workers: Vec::new(),
17588 interactables: vec![],
17589 ledger: None,
17590 career: None,
17591 combat_fx: Vec::new(),
17592 ground_hazards: Vec::new(),
17593 property_plots: Vec::new(),
17594 terrain_overlays: Vec::new(),
17595 };
17596
17597 state.apply_tick_fields(&delta, 1);
17598
17599 assert_eq!(state.entities.len(), 1);
17600 assert!(state.player.is_some());
17601 assert_eq!(state.inventory.get("carrot"), Some(&3));
17602 assert_eq!(state.resource_nodes.len(), 1);
17603 }
17604
17605 #[test]
17606 fn tick_preserves_world_layers_when_delta_omits_them() {
17607 let mut state = sample_state();
17608 let delta = TickDelta {
17609 tick: 1,
17610 entities: state.entities.clone(),
17611 resource_nodes: vec![],
17612 ground_drops: vec![],
17613 placed_containers: vec![],
17614 buildings: vec![],
17615 doors: vec![],
17616 interior_map: None,
17617 npcs: vec![],
17618 inventory: vec![],
17619 blueprints: vec![],
17620 building_materials: vec![],
17621 world_clock: flatland_protocol::WorldClock::default(),
17622 combat: None,
17623 quest_log: vec![],
17624 hired_workers: Vec::new(),
17625 interactables: vec![],
17626 ledger: None,
17627 career: None,
17628 combat_fx: Vec::new(),
17629 ground_hazards: Vec::new(),
17630 property_plots: Vec::new(),
17631 terrain_overlays: Vec::new(),
17632 };
17633
17634 state.apply_tick_fields(&delta, 1);
17635
17636 assert_eq!(state.resource_nodes.len(), 1);
17637 assert_eq!(state.buildings.len(), 1);
17638 assert_eq!(state.doors.len(), 1);
17639 }
17640
17641 #[test]
17642 fn tick_updates_resource_nodes_when_server_sends_them() {
17643 let mut state = sample_state();
17644 let delta = TickDelta {
17645 tick: 1,
17646 entities: state.entities.clone(),
17647 resource_nodes: vec![ResourceNodeView {
17648 id: "oak-1".into(),
17649 label: "Oak".into(),
17650 x: 130.0,
17651 y: 128.0,
17652 z: 0.0,
17653 item_template: "oak_log".into(),
17654 state: ResourceNodeState::Cooldown,
17655 blocking: true,
17656 blocking_radius_m: 0.8,
17657 harvest_off: false,
17658 tile_id: None,
17659 yaw: 0.0,
17660 pitch: 0.0,
17661 roll: 0.0,
17662 draw_scale: 1.0,
17663 sprite_mode: None,
17664 growth_progress: None,
17665 presentation_state: None,
17666 channel_start_tick: None,
17667 channel_end_tick: None,
17668 harvest_drop_templates: vec![],
17669 }],
17670 buildings: vec![],
17671 doors: vec![],
17672 interior_map: None,
17673 npcs: vec![],
17674 inventory: vec![],
17675 blueprints: vec![],
17676 building_materials: vec![],
17677 world_clock: flatland_protocol::WorldClock::default(),
17678 ground_drops: vec![],
17679 placed_containers: vec![],
17680 combat: None,
17681 quest_log: vec![],
17682 hired_workers: Vec::new(),
17683 interactables: vec![],
17684 ledger: None,
17685 career: None,
17686 combat_fx: Vec::new(),
17687 ground_hazards: Vec::new(),
17688 property_plots: Vec::new(),
17689 terrain_overlays: Vec::new(),
17690 };
17691
17692 state.apply_tick_fields(&delta, 1);
17693
17694 assert!(matches!(
17695 state.resource_nodes[0].state,
17696 ResourceNodeState::Cooldown
17697 ));
17698 }
17699
17700 #[test]
17701 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
17702 let mut state = GameState {
17703 session_id: 1,
17704 entity_id: 1,
17705 character_id: None,
17706 tick: 0,
17707 chunk_rev: 0,
17708 content_rev: 0,
17709 publish_rev: 0,
17710 entities: vec![EntityState {
17711 id: 1,
17712 label: "You".into(),
17713 transform: Transform {
17714 position: WorldCoord::surface(4.5, 2.0),
17715 yaw: 0.0,
17716 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
17717 },
17718 vitals: None,
17719 attributes: None,
17720 skills: None,
17721 inside_building: Some("broker_hut".into()),
17722 tile_id: None,
17723 paperdoll_ref: None,
17724 draw_scale: 1.0,
17725 presentation_state: None,
17726 sprite_mode: None,
17727 progression_xp: None,
17728 combat_cues: vec![],
17729 statuses: vec![],
17730 }],
17731 player: None,
17732 resource_nodes: vec![],
17733 ground_drops: vec![],
17734 placed_containers: vec![],
17735 buildings: vec![BuildingView {
17736 id: "broker_hut".into(),
17737 label: "Broker".into(),
17738 x: 158.0,
17739 y: 124.0,
17740 width_m: 8.0,
17741 depth_m: 6.0,
17742 interior_blueprint: Some("broker_hut".into()),
17743 tags: vec![],
17744 market_boundary_zone_ids: vec![],
17745 market_max_volume: None,
17746 wall_set: None,
17747 roof_set: None,
17748 }],
17749 doors: vec![flatland_protocol::DoorView {
17750 id: "broker_hut_exit".into(),
17751 building_id: "broker_hut".into(),
17752 x: 4.3,
17753 y: 0.9,
17754 open: true,
17755 portal: Some("front".into()),
17756 locked: false,
17757 accessible: true,
17758 lock_id: None,
17759 }],
17760 interior_map: None,
17761 npcs: vec![flatland_protocol::NpcView {
17762 id: "ada_broker".into(),
17763 label: "Ada".into(),
17764 x: 4.5,
17765 y: 2.0,
17766 building_id: Some("broker_hut".into()),
17767 role: "broker".into(),
17768 entity_id: None,
17769 life_state: None,
17770 hp_pct: None,
17771 can_trade: true,
17772 buy_templates: vec!["lumber".into()],
17773 tile_id: None,
17774 behavior_state: None,
17775 presentation_state: None,
17776 sprite_mode: None,
17777 paperdoll_ref: None,
17778 draw_scale: 1.0,
17779 yaw: None,
17780 perception_fov_deg: None,
17781 perception_sight_m: None,
17782 perception_hear_m: None,
17783 quest_verbs: Vec::new(),
17784 }],
17785 blueprints: vec![],
17786 building_materials: vec![],
17787 world_x0: 0.0,
17788 world_y0: 0.0,
17789 world_width_m: 256.0,
17790 world_height_m: 256.0,
17791 terrain_zones: Vec::new(),
17792 z_platforms: Vec::new(),
17793 z_transitions: Vec::new(),
17794 z_bands_outdoor_backup: None,
17795 world_clock: flatland_protocol::WorldClock::default(),
17796 inventory: std::collections::HashMap::new(),
17797 inventory_hints: std::collections::HashMap::new(),
17798 item_catalog: std::collections::HashMap::new(),
17799 logs: VecDeque::new(),
17800 intents_sent: 0,
17801 ticks_received: 0,
17802 connected: true,
17803 disconnect_reason: None,
17804 show_stats: false,
17805 hud_log_hidden: false,
17806 show_equip_menu: false,
17807 equip_menu_index: 0,
17808 show_craft_menu: false,
17809 show_plot_build_menu: false,
17810 plot_build_focus_wall: true,
17811 plot_build_wall_index: 0,
17812 plot_build_roof_index: 0,
17813 craft_menu_index: 0,
17814 craft_batch_quantity: 1,
17815 craft_tab: CraftTab::Ready,
17816 craft_filter: String::new(),
17817 craft_filter_focused: false,
17818 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
17819 show_shop_menu: false,
17820 shop_catalog: None,
17821 bank_panel: None,
17822 bank_menu_index: 0,
17823 bank_ui_mode: BankUiMode::Menu,
17824 storage_panel: None,
17825 market_panel: None,
17826 market_menu_index: 0,
17827 market_filter: String::new(),
17828 market_filter_focused: false,
17829 market_category_filter: None,
17830 market_buy_confirm: None,
17831 market_ui_mode: MarketUiMode::Browse,
17832 storage_menu_index: 0,
17833 storage_ui_mode: StorageUiMode::Menu,
17834 shop_tab: ShopTab::default(),
17835 shop_menu_index: 0,
17836 shop_quantity: 1,
17837 shop_trade_log: VecDeque::new(),
17838 show_npc_verb_menu: false,
17839 npc_verb_target: None,
17840 npc_verb_index: 0,
17841 npc_verb_notice: None,
17842 player_verbs: crate::social::PlayerVerbState::default(),
17843 social_chat: crate::social::SocialChatState::default(),
17844 trade_ui: crate::social::TradeUiState::default(),
17845 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
17846 show_npc_chat: false,
17847 npc_chat: None,
17848 show_inventory_menu: false,
17849 inventory_menu_index: 0,
17850 inventory_tab: InventoryTab::OnPerson,
17851 inventory_filter: String::new(),
17852 inventory_filter_focused: false,
17853 show_move_picker: false,
17854 show_rename_prompt: false,
17855 rename_plot_id: None,
17856 highlighted_plot_id: None,
17857 show_worker_rename: false,
17858 rename_buffer: String::new(),
17859 move_picker_index: 0,
17860 move_picker: None,
17861 show_grant_picker: false,
17862 grant_picker_index: 0,
17863 grant_picker: None,
17864 show_destroy_picker: false,
17865 destroy_confirm_pending: false,
17866 destroy_picker: None,
17867 combat_target: None,
17868 combat_target_label: None,
17869 ground_target: None,
17870 combat_fx: Vec::new(),
17871 ground_hazards: Vec::new(),
17872 property_zones: Vec::new(),
17873 tax_zones: Vec::new(),
17874 growth_zones: Vec::new(),
17875 biome_zones: Vec::new(),
17876 terrain_kind_nav: Vec::new(),
17877 property_plots: Vec::new(),
17878 property_plot_settings: None,
17879 claim_mode: None,
17880 relocate_mode: None,
17881 sell_plot_confirm: None,
17882 sell_plot_armed_at: None,
17883 show_plant_menu: false,
17884 plant_menu_index: 0,
17885 show_farm_access: false,
17886 farm_access_name_draft: String::new(),
17887 farm_access_discount_bps: 0,
17888 farm_access_index: 0,
17889 plant_quantity: 1,
17890 in_combat: false,
17891 auto_attack: true,
17892 combat_has_los: false,
17893 attack_cd_ticks: 0,
17894 gcd_ticks: 0,
17895 weapon_ability_id: "unarmed".into(),
17896 mainhand_template_id: None,
17897 mainhand_label: None,
17898 mainhand_instance_id: None,
17899 offhand_template_id: None,
17900 offhand_label: None,
17901 offhand_instance_id: None,
17902 mainhand_hand_slots: 1,
17903 defense: None,
17904 worn: BTreeMap::new(),
17905 carry_mass: 0.0,
17906 carry_mass_max: 0.0,
17907 encumbrance: flatland_protocol::EncumbranceState::Light,
17908 move_speed_mps: 0.0,
17909 move_speed_mult: 0.0,
17910 inventory_stacks: Vec::new(),
17911 keychain_stacks: Vec::new(),
17912 whisper_pouch_stacks: Vec::new(),
17913 combat_target_detail: None,
17914 statuses: Vec::new(),
17915 cast_progress: None,
17916 timed_channel: None,
17917 plot_build_offer: None,
17918 ability_cooldowns: Vec::new(),
17919 blocking_active: false,
17920 max_target_slots: 1,
17921 combat_slots: Vec::new(),
17922 rotation_presets: Vec::new(),
17923 known_abilities: Vec::new(),
17924 ability_meta: std::collections::HashMap::new(),
17925 ability_mastery: std::collections::HashMap::new(),
17926 hotbar: vec![None; 9],
17927 max_abilities_per_rotation: 0,
17928 show_loadout_menu: false,
17929 show_keychain_menu: false,
17930 keychain_menu_index: 0,
17931 show_rotation_editor: false,
17932 loadout_menu_index: 0,
17933 loadout_hotbar_slot: 1,
17934 loadout_ability_index: 0,
17935 loadout_focus_presets: false,
17936 rotation_editor: RotationEditorState::default(),
17937 harvest_in_progress: false,
17938 harvest_started_at: None,
17939 pending_craft_ack: None,
17940 craft_channel_blueprint_id: None,
17941 pending_worker_job_ack: None,
17942 attending_worker_instance_id: None,
17943 quest_log: Vec::new(),
17944 interactables: Vec::new(),
17945 ledger: None,
17946 career: None,
17947 character_sheet_tab: CharacterSheetTab::Character,
17948 ledger_period: LedgerPeriod::Day,
17949 show_quest_offer: false,
17950 pending_quest_offers: Vec::new(),
17951 quest_offer_index: 0,
17952 show_quest_menu: false,
17953 quest_menu_index: 0,
17954 quest_withdraw_confirm: false,
17955 hired_workers: Vec::new(),
17956 show_workers_menu: false,
17957 workers_menu_index: 0,
17958 worker_dismiss_confirmation: None,
17959 workers_menu_compact: false,
17960 worker_step_display: BTreeMap::new(),
17961 worker_error_display: BTreeMap::new(),
17962 worker_health_ring_until: BTreeMap::new(),
17963 pending_worker_hire_since: None,
17964 show_worker_give_picker: false,
17965 worker_give_picker_index: 0,
17966 worker_give_picker: None,
17967 show_worker_give_target_picker: false,
17968 worker_give_target_picker_index: 0,
17969 worker_give_target_picker: None,
17970 show_worker_take_picker: false,
17971 worker_take_picker_index: 0,
17972 worker_take_picker: None,
17973 show_worker_teach_picker: false,
17974 worker_teach_picker_index: 0,
17975 worker_teach_picker: None,
17976 worker_route_editor: None,
17977 progression_curve: None,
17978 };
17979 state.player = state.entities.first().cloned();
17980 assert_eq!(
17981 state.nearest_interact_target().as_deref(),
17982 Some("ada_broker")
17983 );
17984 }
17985
17986 #[test]
17987 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
17988 let mut state = sample_state();
17989 state.placed_containers = vec![
17992 flatland_protocol::PlacedContainerView {
17993 id: "near".into(),
17994 template_id: "wooden_chest_small".into(),
17995 display_name: "Wooden Chest".into(),
17996 x: 130.0,
17997 y: 128.0,
17998 z: 0.0,
17999 locked: true,
18000 accessible: true,
18001 owner_character_id: None,
18002 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
18003 lock_id: None,
18004 capacity_volume: None,
18005 item_instance_id: Some(uuid::Uuid::from_u128(1)),
18006 tile_id: None,
18007 worker_lodging_capacity: None,
18008 blocking: false,
18009 blocking_radius_m: 0.0,
18010 building_id: None,
18011 },
18012 flatland_protocol::PlacedContainerView {
18013 id: "far".into(),
18014 template_id: "wooden_chest_small".into(),
18015 display_name: "Distant Chest".into(),
18016 x: 128.0 + CONTAINER_RANGE_M + 5.0,
18017 y: 128.0,
18018 z: 0.0,
18019 locked: false,
18020 accessible: true,
18021 owner_character_id: None,
18022 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
18023 lock_id: None,
18024 capacity_volume: None,
18025 item_instance_id: Some(uuid::Uuid::from_u128(2)),
18026 tile_id: None,
18027 worker_lodging_capacity: None,
18028 blocking: false,
18029 blocking_radius_m: 0.0,
18030 building_id: None,
18031 },
18032 ];
18033
18034 let nearby = state.nearby_containers();
18035 assert_eq!(
18036 nearby.len(),
18037 1,
18038 "far chest must not appear once out of range"
18039 );
18040 assert_eq!(nearby[0].view.id, "near");
18041 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
18042 assert!(nearby[0].rows[0].is_chest_shell);
18043
18044 state.placed_containers[0].accessible = false;
18047 let nearby = state.nearby_containers();
18048 assert_eq!(nearby.len(), 1);
18049 assert_eq!(nearby[0].rows.len(), 1);
18050 assert!(nearby[0].rows[0].is_chest_shell);
18051 }
18052
18053 #[test]
18054 fn chest_pickup_destinations_offer_person_and_worn_bag() {
18055 let mut state = sample_state();
18056 let back_id = uuid::Uuid::from_u128(42);
18057 state.worn.insert(
18058 BodySlot::Back,
18059 flatland_protocol::ItemStack {
18060 template_id: "travel_backpack".into(),
18061 quantity: 1,
18062 item_instance_id: Some(back_id),
18063 props: Default::default(),
18064 status_bindings: Vec::new(),
18065 contents: Vec::new(),
18066 display_name: Some("Travel Backpack".into()),
18067 category: Some("container".into()),
18068 base_mass: Some(2.5),
18069 base_volume: Some(12.0),
18070 capacity_volume: Some(80.0),
18071 stackable: Some(false),
18072 world_placeable: Some(false),
18073 worker_lodging_capacity: None,
18074 equip_slot: None,
18075 armor_physical: None,
18076 resists: vec![],
18077 hand_slots: None,
18078 listable: None,
18079 ..Default::default()
18080 },
18081 );
18082 let opts = state.chest_pickup_destinations("chest-1");
18083 assert!(matches!(
18084 opts.first().map(|o| &o.kind),
18085 Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
18086 ));
18087 assert!(opts.iter().any(|o| matches!(
18088 &o.kind,
18089 MoveOptionKind::PickupPlaced {
18090 nest_parent_instance_id: None,
18091 ..
18092 }
18093 )));
18094 assert!(opts.iter().any(|o| matches!(
18095 &o.kind,
18096 MoveOptionKind::PickupPlaced {
18097 nest_parent_instance_id: Some(id),
18098 ..
18099 } if *id == back_id
18100 )));
18101 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18102 }
18103
18104 #[test]
18105 fn placed_container_public_label_hides_owner_custom_name() {
18106 let owner = uuid::Uuid::from_u128(99);
18107 let mut state = sample_state();
18108 state.character_id = Some(uuid::Uuid::from_u128(1));
18109 state.inventory_hints.insert(
18110 "wooden_chest_medium".into(),
18111 InventoryHint {
18112 display_name: "Medium Wooden Chest".into(),
18113 category: "container".into(),
18114 base_mass: None,
18115 base_volume: None,
18116 capacity_volume: None,
18117 stackable: false,
18118 listable: true,
18119 base_value_copper: None,
18120 },
18121 );
18122 let chest = flatland_protocol::PlacedContainerView {
18123 id: "c1".into(),
18124 template_id: "wooden_chest_medium".into(),
18125 display_name: "Barry's Loot #a3f2".into(),
18126 x: 128.0,
18127 y: 128.0,
18128 z: 0.0,
18129 locked: false,
18130 accessible: true,
18131 owner_character_id: Some(owner),
18132 contents: vec![],
18133 lock_id: None,
18134 capacity_volume: None,
18135 item_instance_id: None,
18136 tile_id: None,
18137 worker_lodging_capacity: None,
18138 blocking: false,
18139 blocking_radius_m: 0.0,
18140 building_id: None,
18141 };
18142 assert_eq!(
18143 state.placed_container_public_label(&chest),
18144 "Medium Wooden Chest"
18145 );
18146 state.character_id = Some(owner);
18147 assert_eq!(
18148 state.placed_container_public_label(&chest),
18149 "Barry's Loot #a3f2"
18150 );
18151 }
18152
18153 #[test]
18154 fn location_context_shows_crop_growth_percent_not_depleted() {
18155 let mut state = sample_state();
18156 state.player = state.entities.first().cloned();
18157 state.resource_nodes[0].label = "Carrot (growing)".into();
18158 state.resource_nodes[0].x = 128.2;
18159 state.resource_nodes[0].y = 128.0;
18160 state.resource_nodes[0].state = ResourceNodeState::Cooldown;
18161 state.resource_nodes[0].growth_progress = Some(0.47);
18162 let lines = state.location_context_lines();
18163 let line = lines
18164 .iter()
18165 .find(|l| l.text.contains("Carrot"))
18166 .map(|l| l.text.as_str())
18167 .unwrap_or("");
18168 assert!(
18169 line.contains("(growing, 47%)"),
18170 "expected growth percent, got: {line}"
18171 );
18172 assert!(
18173 !line.contains("depleted"),
18174 "growing crop should not show depleted: {line}"
18175 );
18176 }
18177
18178 #[test]
18179 fn resource_node_near_action_suffix_prefers_growth() {
18180 let node = ResourceNodeView {
18181 id: "crop".into(),
18182 label: "Wheat".into(),
18183 x: 0.0,
18184 y: 0.0,
18185 z: 0.0,
18186 item_template: "wheat".into(),
18187 state: ResourceNodeState::Cooldown,
18188 blocking: false,
18189 blocking_radius_m: 0.0,
18190 harvest_off: false,
18191 tile_id: None,
18192 yaw: 0.0,
18193 pitch: 0.0,
18194 roll: 0.0,
18195 draw_scale: 1.0,
18196 sprite_mode: None,
18197 growth_progress: Some(0.12),
18198 presentation_state: None,
18199 channel_start_tick: None,
18200 channel_end_tick: None,
18201 harvest_drop_templates: vec![],
18202 };
18203 assert_eq!(resource_node_near_action_suffix(&node), " (growing, 12%)");
18204 }
18205
18206 #[test]
18207 fn location_context_lists_nearby_resource_node() {
18208 let mut state = sample_state();
18209 state.player = state.entities.first().cloned();
18210 state.resource_nodes[0].x = 128.2;
18211 state.resource_nodes[0].y = 128.0;
18212 let lines = state.location_context_lines();
18213 assert!(
18214 lines
18215 .iter()
18216 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
18217 "expected resource node in context: {:?}",
18218 lines
18219 );
18220 }
18221
18222 #[test]
18223 fn quest_board_usable_within_board_radius() {
18224 let mut state = sample_state();
18225 state.player = state.entities.first().cloned();
18226 state.interactables = vec![flatland_protocol::InteractableView {
18227 id: "board-1".into(),
18228 kind: "quest_board".into(),
18229 label: "Town Quest Board".into(),
18230 x: 130.5,
18231 y: 128.0,
18232 z: 0.0,
18233 board_id: Some("starter_town_board".into()),
18234 }];
18235 assert_eq!(
18237 state.nearest_interact_target().as_deref(),
18238 Some("board-1"),
18239 "quest board should be selectable at ~2.5m"
18240 );
18241 let lines = state.location_context_lines();
18242 assert!(
18243 lines
18244 .iter()
18245 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
18246 "HUD should advertise f when board is in range: {:?}",
18247 lines
18248 );
18249 }
18250
18251 #[test]
18252 fn quest_board_keeps_multiple_offers() {
18253 let mut state = sample_state();
18254 let offer = |id: &str, title: &str| flatland_protocol::QuestOffer {
18255 quest_id: id.into(),
18256 title: title.into(),
18257 description: format!("{title} desc"),
18258 step_count: 2,
18259 };
18260 state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18261 state.push_quest_offer(offer("daily_20695_1", "Town board: Deer threat"));
18262 state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18263 assert_eq!(state.pending_quest_offers.len(), 2);
18264 assert_eq!(
18265 state.selected_quest_offer().unwrap().quest_id,
18266 "ada_goblin_hunt"
18267 );
18268 state.move_quest_offer_selection(1);
18269 assert_eq!(
18270 state.selected_quest_offer().unwrap().quest_id,
18271 "daily_20695_1"
18272 );
18273 state.remove_quest_offer("daily_20695_1");
18274 assert_eq!(state.pending_quest_offers.len(), 1);
18275 assert!(state.show_quest_offer);
18276 state.remove_quest_offer("ada_goblin_hunt");
18277 assert!(!state.show_quest_offer);
18278 assert!(state.pending_quest_offers.is_empty());
18279 }
18280
18281 #[test]
18282 fn inventory_selectable_rows_excludes_equipped_shells_but_keeps_bag_contents() {
18283 let mut state = sample_state();
18284 state.worn.insert(
18285 BodySlot::Back,
18286 flatland_protocol::ItemStack {
18287 template_id: "travel_backpack".into(),
18288 quantity: 1,
18289 item_instance_id: Some(uuid::Uuid::from_u128(3)),
18290 props: Default::default(),
18291 status_bindings: Vec::new(),
18292 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
18293 display_name: None,
18294 category: None,
18295 base_mass: None,
18296 base_volume: None,
18297 capacity_volume: None,
18298 stackable: None,
18299 world_placeable: None,
18300 worker_lodging_capacity: None,
18301 equip_slot: None,
18302 armor_physical: None,
18303 resists: vec![],
18304 hand_slots: None,
18305 listable: None,
18306 ..Default::default()
18307 },
18308 );
18309 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
18310 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18311 id: "chest-1".into(),
18312 template_id: "wooden_chest_small".into(),
18313 display_name: "Wooden Chest".into(),
18314 x: 129.0,
18315 y: 128.0,
18316 z: 0.0,
18317 locked: false,
18318 accessible: true,
18319 owner_character_id: None,
18320 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
18321 lock_id: None,
18322 capacity_volume: None,
18323 item_instance_id: Some(uuid::Uuid::from_u128(4)),
18324 tile_id: None,
18325 worker_lodging_capacity: None,
18326 blocking: false,
18327 blocking_radius_m: 0.0,
18328 building_id: None,
18329 }];
18330
18331 state.inventory_tab = InventoryTab::OnPerson;
18332 let rows = state.inventory_selectable_rows();
18333 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
18334 assert_eq!(
18335 sections,
18336 vec![
18337 InventorySection::Person, InventorySection::Person, ]
18340 );
18341 assert_eq!(rows[0].stack.template_id, "iron_ore");
18342 assert_eq!(rows[0].depth, 0);
18343 assert!(!rows[0].is_equip_shell);
18344 assert_eq!(rows[1].stack.template_id, "lumber");
18345
18346 let lines = state.inventory_browser_lines();
18347 assert!(lines.iter().any(|l| matches!(
18348 l,
18349 InventoryBrowserLine::Section(s) if s.contains("carried bags")
18350 )));
18351 assert!(lines.iter().any(|l| matches!(
18352 l,
18353 InventoryBrowserLine::Item { text, .. } if text.contains("iron_ore")
18354 )));
18355 assert!(!lines.iter().any(|l| matches!(
18356 l,
18357 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
18358 )));
18359 assert!(!lines.iter().any(|l| matches!(
18360 l,
18361 InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
18362 )));
18363
18364 state.inventory_tab = InventoryTab::Nearby;
18365 let nearby_rows = state.inventory_selectable_rows();
18366 assert_eq!(nearby_rows.len(), 2);
18367 assert!(nearby_rows[0].is_chest_shell);
18368 assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
18369 let nearby_lines = state.inventory_browser_lines();
18370 assert!(nearby_lines.iter().any(|l| matches!(
18371 l,
18372 InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
18373 )));
18374 }
18375
18376 #[test]
18377 fn give_worker_notice_does_not_put_item_back_in_bag() {
18378 let mut state = sample_state();
18379 let id = uuid::Uuid::from_u128(42);
18380 let mut saw = flatland_protocol::ItemStack::simple("handsaw", 1);
18381 saw.item_instance_id = Some(id);
18382 saw.display_name = Some("Handsaw".into());
18383 state.sync_inventory_from_stacks(&[saw]);
18384 assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 1);
18385
18386 state.remove_carried_instance(id, None);
18387 assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 0);
18388 assert!(state.inventory_stacks.is_empty());
18389
18390 state.apply_interaction_notice(&flatland_protocol::InteractionNotice {
18391 target_id: "worker-1".into(),
18392 message: "Gave 1x Handsaw to Laborer".into(),
18393 coins_delta: 0,
18394 inventory_delta: vec![flatland_protocol::ItemStack::simple("handsaw", 1)],
18395 });
18396 assert_eq!(
18397 state.inventory.get("handsaw").copied().unwrap_or(0),
18398 0,
18399 "Gave notice must not restore the handed stack"
18400 );
18401 }
18402
18403 #[test]
18404 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
18405 let mut state = sample_state();
18406 let back_id = uuid::Uuid::from_u128(5);
18407 state.worn.insert(
18408 BodySlot::Back,
18409 flatland_protocol::ItemStack {
18410 template_id: "travel_backpack".into(),
18411 quantity: 1,
18412 item_instance_id: Some(back_id),
18413 props: Default::default(),
18414 status_bindings: Vec::new(),
18415 contents: Vec::new(),
18416 display_name: None,
18417 category: Some("container".into()),
18418 base_mass: None,
18419 base_volume: None,
18420 capacity_volume: Some(80.0),
18421 stackable: None,
18422 world_placeable: None,
18423 worker_lodging_capacity: None,
18424 equip_slot: None,
18425 armor_physical: None,
18426 resists: vec![],
18427 hand_slots: None,
18428 listable: None,
18429 ..Default::default()
18430 },
18431 );
18432 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18433 id: "chest-1".into(),
18434 template_id: "wooden_chest_small".into(),
18435 display_name: "Wooden Chest".into(),
18436 x: 129.0,
18437 y: 128.0,
18438 z: 0.0,
18439 locked: false,
18440 accessible: true,
18441 owner_character_id: None,
18442 contents: Vec::new(),
18443 lock_id: None,
18444 capacity_volume: None,
18445 item_instance_id: Some(uuid::Uuid::from_u128(6)),
18446 tile_id: None,
18447 worker_lodging_capacity: None,
18448 blocking: false,
18449 blocking_radius_m: 0.0,
18450 building_id: None,
18451 }];
18452
18453 let opts = state.move_destinations_for(
18456 &flatland_protocol::InventoryLocation::Root,
18457 None,
18458 None,
18459 "lumber",
18460 );
18461 assert!(!opts.iter().any(|o| matches!(
18462 &o.kind,
18463 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18464 )));
18465 assert!(opts.iter().any(|o| matches!(
18466 &o.kind,
18467 MoveOptionKind::Move { location, parent_instance_id, .. }
18468 if *location == flatland_protocol::InventoryLocation::Worn {
18469 slot: BodySlot::Back,
18470 } && *parent_instance_id == Some(back_id)
18471 )));
18472 assert!(opts.iter().any(|o| matches!(
18473 &o.kind,
18474 MoveOptionKind::Move { location, .. }
18475 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
18476 )));
18477 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18478 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
18479
18480 let from_backpack = flatland_protocol::InventoryLocation::Worn {
18484 slot: BodySlot::Back,
18485 };
18486 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
18487 assert!(!opts.iter().any(|o| matches!(
18488 &o.kind,
18489 MoveOptionKind::Move { location, parent_instance_id, .. }
18490 if *location == from_backpack && *parent_instance_id == Some(back_id)
18491 )));
18492 assert!(opts.iter().any(|o| matches!(
18493 &o.kind,
18494 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18495 )));
18496 }
18497
18498 #[test]
18499 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
18500 let mut state = sample_state();
18501 state.worn.insert(
18504 BodySlot::Waist,
18505 flatland_protocol::ItemStack {
18506 template_id: "simple_belt".into(),
18507 quantity: 1,
18508 item_instance_id: Some(uuid::Uuid::from_u128(10)),
18509 props: Default::default(),
18510 status_bindings: Vec::new(),
18511 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
18512 display_name: None,
18513 category: Some("container".into()),
18514 base_mass: None,
18515 base_volume: None,
18516 capacity_volume: None,
18517 stackable: None,
18518 world_placeable: None,
18519 worker_lodging_capacity: None,
18520 equip_slot: None,
18521 armor_physical: None,
18522 resists: vec![],
18523 hand_slots: None,
18524 listable: None,
18525 ..Default::default()
18526 },
18527 );
18528 state.worn.insert(
18529 BodySlot::Head,
18530 flatland_protocol::ItemStack {
18531 template_id: "cloth_cap".into(),
18532 quantity: 1,
18533 item_instance_id: Some(uuid::Uuid::from_u128(11)),
18534 props: Default::default(),
18535 status_bindings: Vec::new(),
18536 contents: Vec::new(),
18537 display_name: None,
18538 category: Some("armor".into()),
18539 base_mass: None,
18540 base_volume: None,
18541 capacity_volume: None,
18542 stackable: None,
18543 world_placeable: None,
18544 worker_lodging_capacity: None,
18545 equip_slot: None,
18546 armor_physical: None,
18547 resists: vec![],
18548 hand_slots: None,
18549 listable: None,
18550 ..Default::default()
18551 },
18552 );
18553 state.worn.insert(
18554 BodySlot::Back,
18555 flatland_protocol::ItemStack {
18556 template_id: "travel_backpack".into(),
18557 quantity: 1,
18558 item_instance_id: Some(uuid::Uuid::from_u128(12)),
18559 props: Default::default(),
18560 status_bindings: Vec::new(),
18561 contents: Vec::new(),
18562 display_name: None,
18563 category: Some("container".into()),
18564 base_mass: None,
18565 base_volume: None,
18566 capacity_volume: None,
18567 stackable: None,
18568 world_placeable: None,
18569 worker_lodging_capacity: None,
18570 equip_slot: None,
18571 armor_physical: None,
18572 resists: vec![],
18573 hand_slots: None,
18574 listable: None,
18575 ..Default::default()
18576 },
18577 );
18578
18579 let rows = state.worn_rows();
18580 assert_eq!(rows.len(), 4);
18582 assert_eq!(rows[0].stack.template_id, "cloth_cap");
18583 assert!(rows[0].is_equip_shell);
18584 assert_eq!(rows[1].stack.template_id, "travel_backpack");
18585 assert!(rows[1].is_equip_shell);
18586 assert_eq!(rows[2].stack.template_id, "simple_belt");
18587 assert!(rows[2].is_equip_shell);
18588 assert_eq!(rows[3].stack.template_id, "leather_pouch");
18589 assert_eq!(rows[3].depth, 1);
18590 assert!(!rows[3].is_equip_shell);
18591 }
18592
18593 #[test]
18594 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
18595 let mut state = sample_state();
18596 state.worn.insert(
18597 BodySlot::Waist,
18598 flatland_protocol::ItemStack {
18599 template_id: "simple_belt".into(),
18600 quantity: 1,
18601 item_instance_id: Some(uuid::Uuid::from_u128(20)),
18602 props: Default::default(),
18603 status_bindings: Vec::new(),
18604 contents: Vec::new(),
18605 display_name: Some("Simple Belt".into()),
18606 category: Some("container".into()),
18607 base_mass: None,
18608 base_volume: None,
18609 capacity_volume: None,
18610 stackable: None,
18611 world_placeable: None,
18612 worker_lodging_capacity: None,
18613 equip_slot: None,
18614 armor_physical: None,
18615 resists: vec![],
18616 hand_slots: None,
18617 listable: None,
18618 ..Default::default()
18619 },
18620 );
18621 state.worn.insert(
18622 BodySlot::Head,
18623 flatland_protocol::ItemStack {
18624 template_id: "cloth_cap".into(),
18625 quantity: 1,
18626 item_instance_id: Some(uuid::Uuid::from_u128(21)),
18627 props: Default::default(),
18628 status_bindings: Vec::new(),
18629 contents: Vec::new(),
18630 display_name: Some("Cloth Cap".into()),
18631 category: Some("armor".into()),
18632 base_mass: None,
18633 base_volume: None,
18634 capacity_volume: None,
18635 stackable: None,
18636 world_placeable: None,
18637 worker_lodging_capacity: None,
18638 equip_slot: None,
18639 armor_physical: None,
18640 resists: vec![],
18641 hand_slots: None,
18642 listable: None,
18643 ..Default::default()
18644 },
18645 );
18646
18647 let opts = state.move_destinations_for(
18648 &flatland_protocol::InventoryLocation::Root,
18649 None,
18650 None,
18651 "leather_pouch",
18652 );
18653 assert!(
18654 opts.iter().any(|o| matches!(
18655 &o.kind,
18656 MoveOptionKind::Move { location, .. }
18657 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18658 )),
18659 "belt loop must be offered when moving a pouch"
18660 );
18661 assert!(
18662 !opts.iter().any(|o| matches!(
18663 &o.kind,
18664 MoveOptionKind::Move { location, .. }
18665 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
18666 )),
18667 "armor slots can't hold other items and must not appear as move destinations"
18668 );
18669 let belt_opt = opts
18670 .iter()
18671 .find(|o| matches!(
18672 &o.kind,
18673 MoveOptionKind::Move { location, .. }
18674 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18675 ))
18676 .unwrap();
18677 assert!(belt_opt.label.contains("belt loop"));
18678
18679 let opts = state.move_destinations_for(
18680 &flatland_protocol::InventoryLocation::Root,
18681 None,
18682 None,
18683 "lumber",
18684 );
18685 assert!(
18686 !opts.iter().any(|o| o.label.contains("belt loop")),
18687 "loose materials must not target the belt shell — only nested pouches"
18688 );
18689 }
18690
18691 #[test]
18692 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
18693 let mut state = sample_state();
18694 let belt_id = uuid::Uuid::from_u128(30);
18695 let pouch_id = uuid::Uuid::from_u128(31);
18696 state.worn.insert(
18697 BodySlot::Waist,
18698 flatland_protocol::ItemStack {
18699 template_id: "simple_belt".into(),
18700 quantity: 1,
18701 item_instance_id: Some(belt_id),
18702 props: Default::default(),
18703 status_bindings: Vec::new(),
18704 world_placeable: None,
18705 worker_lodging_capacity: None,
18706 equip_slot: None,
18707 armor_physical: None,
18708 resists: vec![],
18709 hand_slots: None,
18710 contents: vec![flatland_protocol::ItemStack {
18711 template_id: "dimensional_pouch".into(),
18712 quantity: 1,
18713 item_instance_id: Some(pouch_id),
18714 props: Default::default(),
18715 status_bindings: Vec::new(),
18716 contents: Vec::new(),
18717 display_name: Some("Dimensional Pouch".into()),
18718 category: Some("container".into()),
18719 base_mass: None,
18720 base_volume: None,
18721 capacity_volume: Some(200.0),
18722 stackable: None,
18723 world_placeable: None,
18724 worker_lodging_capacity: None,
18725 equip_slot: None,
18726 armor_physical: None,
18727 resists: vec![],
18728 hand_slots: None,
18729 listable: None,
18730 ..Default::default()
18731 }],
18732 display_name: Some("Simple Belt".into()),
18733 category: Some("container".into()),
18734 base_mass: None,
18735 base_volume: None,
18736 capacity_volume: None,
18737 stackable: None,
18738 listable: None,
18739 ..Default::default()
18740 },
18741 );
18742
18743 let opts = state.move_destinations_for(
18744 &flatland_protocol::InventoryLocation::Root,
18745 None,
18746 None,
18747 "iron_ore",
18748 );
18749 assert!(
18750 opts.iter().any(|o| matches!(
18751 &o.kind,
18752 MoveOptionKind::Move {
18753 location,
18754 parent_instance_id,
18755 ..
18756 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18757 && *parent_instance_id == Some(pouch_id)
18758 )),
18759 "dimensional pouch clipped on belt must accept loose items"
18760 );
18761 assert!(
18762 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
18763 "destination label should name the pouch"
18764 );
18765 }
18766
18767 #[test]
18768 fn container_volume_label_on_placed_chest_shell() {
18769 let mut state = sample_state();
18770 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18771 id: "chest-1".into(),
18772 template_id: "wooden_chest_small".into(),
18773 display_name: "Camp Chest".into(),
18774 x: 129.0,
18775 y: 128.0,
18776 z: 0.0,
18777 locked: false,
18778 accessible: true,
18779 owner_character_id: None,
18780 contents: vec![flatland_protocol::ItemStack {
18781 template_id: "iron_ore".into(),
18782 quantity: 2,
18783 item_instance_id: None,
18784 props: Default::default(),
18785 status_bindings: Vec::new(),
18786 contents: Vec::new(),
18787 display_name: None,
18788 category: None,
18789 base_mass: None,
18790 base_volume: Some(2.0),
18791 capacity_volume: None,
18792 stackable: None,
18793 world_placeable: None,
18794 worker_lodging_capacity: None,
18795 equip_slot: None,
18796 armor_physical: None,
18797 resists: vec![],
18798 hand_slots: None,
18799 listable: None,
18800 ..Default::default()
18801 }],
18802 lock_id: None,
18803 capacity_volume: Some(60.0),
18804 item_instance_id: Some(uuid::Uuid::from_u128(4)),
18805 tile_id: None,
18806 worker_lodging_capacity: None,
18807 blocking: false,
18808 blocking_radius_m: 0.0,
18809 building_id: None,
18810 }];
18811 let nearby = state.nearby_containers();
18812 let label = state.container_volume_label(&nearby[0].rows[0]);
18813 assert!(
18814 label.contains("vol 4/60"),
18815 "expected used/cap in label, got {label}"
18816 );
18817 assert!(
18818 label.contains("56 free"),
18819 "expected free space, got {label}"
18820 );
18821 }
18822
18823 #[test]
18824 fn key_pair_chest_label_from_placed_lock_id() {
18825 let mut state = sample_state();
18826 let owner = uuid::Uuid::from_u128(77);
18827 state.character_id = Some(owner);
18828 let lock = uuid::Uuid::from_u128(99).to_string();
18829 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18830 id: "chest-1".into(),
18831 template_id: "wooden_chest_small".into(),
18832 display_name: "Barry's Loot #a3f2".into(),
18833 x: 129.0,
18834 y: 128.0,
18835 z: 0.0,
18836 locked: true,
18837 accessible: true,
18838 owner_character_id: Some(owner),
18839 contents: Vec::new(),
18840 lock_id: Some(lock.clone()),
18841 capacity_volume: None,
18842 item_instance_id: Some(uuid::Uuid::from_u128(4)),
18843 tile_id: None,
18844 worker_lodging_capacity: None,
18845 blocking: false,
18846 blocking_radius_m: 0.0,
18847 building_id: None,
18848 }];
18849 let key_id = uuid::Uuid::from_u128(5);
18850 let key = flatland_protocol::ItemStack {
18851 template_id: KEY_TEMPLATE.into(),
18852 quantity: 1,
18853 item_instance_id: Some(key_id),
18854 props: BTreeMap::from([
18855 (PROP_OPENS_LOCK_ID.into(), lock),
18856 (
18857 PROP_OPENS_CONTAINER_NAME.into(),
18858 "Barry's Loot #a3f2".into(),
18859 ),
18860 ]),
18861 status_bindings: Vec::new(),
18862 contents: Vec::new(),
18863 display_name: Some("Container Key".into()),
18864 category: Some("key".into()),
18865 base_mass: None,
18866 base_volume: None,
18867 capacity_volume: None,
18868 stackable: None,
18869 world_placeable: None,
18870 worker_lodging_capacity: None,
18871 equip_slot: None,
18872 armor_physical: None,
18873 resists: vec![],
18874 hand_slots: None,
18875 listable: None,
18876 ..Default::default()
18877 };
18878 state.inventory_stacks = vec![key.clone()];
18879 assert_eq!(
18880 state.key_pair_chest_label(&key).as_deref(),
18881 Some("Barry's Loot #a3f2")
18882 );
18883 assert!(state.key_drop_blocked(&key));
18884 }
18885
18886 #[test]
18887 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
18888 let mut state = sample_state();
18889 let lock = uuid::Uuid::from_u128(101).to_string();
18890 let key = flatland_protocol::ItemStack {
18891 template_id: KEY_TEMPLATE.into(),
18892 quantity: 1,
18893 item_instance_id: Some(uuid::Uuid::from_u128(7)),
18894 props: BTreeMap::from([
18895 (PROP_OPENS_LOCK_ID.into(), lock),
18896 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
18897 ]),
18898 status_bindings: Vec::new(),
18899 contents: Vec::new(),
18900 display_name: None,
18901 category: Some("key".into()),
18902 base_mass: None,
18903 base_volume: None,
18904 capacity_volume: None,
18905 stackable: None,
18906 world_placeable: None,
18907 worker_lodging_capacity: None,
18908 equip_slot: None,
18909 armor_physical: None,
18910 resists: vec![],
18911 hand_slots: None,
18912 listable: None,
18913 ..Default::default()
18914 };
18915 state.placed_containers.clear();
18916 assert_eq!(
18917 state.key_pair_chest_label(&key).as_deref(),
18918 Some("Camp Stash")
18919 );
18920 }
18921
18922 #[test]
18923 fn key_drop_allowed_when_paired_chest_unlocked() {
18924 let mut state = sample_state();
18925 let lock = uuid::Uuid::from_u128(100).to_string();
18926 let key_id = uuid::Uuid::from_u128(6);
18927 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18928 id: "chest-1".into(),
18929 template_id: "wooden_chest_small".into(),
18930 display_name: "Camp Chest".into(),
18931 x: 129.0,
18932 y: 128.0,
18933 z: 0.0,
18934 locked: false,
18935 accessible: true,
18936 owner_character_id: None,
18937 contents: Vec::new(),
18938 lock_id: Some(lock.clone()),
18939 capacity_volume: None,
18940 item_instance_id: None,
18941 tile_id: None,
18942 worker_lodging_capacity: None,
18943 blocking: false,
18944 blocking_radius_m: 0.0,
18945 building_id: None,
18946 }];
18947 let key = flatland_protocol::ItemStack {
18948 template_id: KEY_TEMPLATE.into(),
18949 quantity: 1,
18950 item_instance_id: Some(key_id),
18951 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
18952 status_bindings: Vec::new(),
18953 contents: Vec::new(),
18954 display_name: None,
18955 category: Some("key".into()),
18956 base_mass: None,
18957 base_volume: None,
18958 capacity_volume: None,
18959 stackable: None,
18960 world_placeable: None,
18961 worker_lodging_capacity: None,
18962 equip_slot: None,
18963 armor_physical: None,
18964 resists: vec![],
18965 hand_slots: None,
18966 listable: None,
18967 ..Default::default()
18968 };
18969 state.inventory_stacks = vec![key.clone()];
18970 assert!(!state.key_drop_blocked(&key));
18971 let opts = state.move_destinations_for(
18972 &flatland_protocol::InventoryLocation::Root,
18973 None,
18974 Some(key_id),
18975 KEY_TEMPLATE,
18976 );
18977 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
18978 }
18979
18980 #[test]
18981 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
18982 use flatland_protocol::{CombatHud, ProgressionCurve, ProgressionXp};
18983
18984 let mut state = sample_state();
18985 let curve = ProgressionCurve::default();
18986 let bootstrap =
18987 ProgressionXp::bootstrap_new(curve.baseline_display, curve.xp_base, curve.xp_growth);
18988 let mut fresh = bootstrap.clone();
18989 fresh.strength += 0.08;
18990 if let Some(player) = state.player.as_mut() {
18991 player.progression_xp = Some(bootstrap);
18992 }
18993
18994 let combat = CombatHud {
18995 progression_xp: Some(fresh.clone()),
18996 progression_baseline: curve.baseline_display,
18997 progression_xp_base: curve.xp_base,
18998 progression_xp_growth: curve.xp_growth,
18999 attributes: state.player.as_ref().and_then(|p| p.attributes),
19000 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
19001 ..CombatHud::default()
19002 };
19003 state.apply_combat_hud(&combat);
19004
19005 let xp = state
19006 .player
19007 .as_ref()
19008 .and_then(|p| p.progression_xp.as_ref())
19009 .expect("xp");
19010 assert!((xp.strength - fresh.strength).abs() < 0.001);
19011 assert!(state.progression_curve.is_some());
19012 }
19013
19014 #[test]
19015 fn combat_hud_syncs_known_abilities_and_hotbar() {
19016 use flatland_protocol::CombatHud;
19017
19018 let mut state = sample_state();
19019 let combat = CombatHud {
19020 known_abilities: vec!["unarmed".into(), "fireball".into()],
19021 hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
19022 max_abilities_per_rotation: 4,
19023 ability_id: "short_sword_slash".into(),
19024 ..CombatHud::default()
19025 };
19026 state.apply_combat_hud(&combat);
19027
19028 assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
19029 assert_eq!(state.hotbar_ability(1), Some("fireball"));
19030 assert_eq!(state.hotbar_ability(2), None);
19031 assert_eq!(state.hotbar_ability(3), Some("unarmed"));
19032 assert_eq!(state.max_abilities_per_rotation, 4);
19033 let choices = state.loadout_ability_choices();
19034 assert!(choices.iter().any(|a| a == "short_sword_slash"));
19035 assert!(choices.iter().any(|a| a == "fireball"));
19036 }
19037
19038 #[test]
19039 fn loadout_hotbar_choices_include_inventory_consumables() {
19040 let mut state = sample_state();
19041 state.known_abilities = vec!["unarmed".into()];
19042 state.weapon_ability_id = "unarmed".into();
19043 state.inventory_stacks = vec![flatland_protocol::ItemStack {
19044 template_id: "empty_bottle".into(),
19045 quantity: 1,
19046 item_instance_id: Some(uuid::Uuid::from_u128(9)),
19047 display_name: Some("Glass Bottle of Water".into()),
19048 category: Some("container".into()),
19049 props: [
19050 ("serving".into(), "1".into()),
19051 ("liquid_vessel".into(), "1".into()),
19052 ("serving_holds".into(), "liquid".into()),
19053 ]
19054 .into_iter()
19055 .collect(),
19056 ..Default::default()
19057 }];
19058 state.inventory.insert("empty_bottle".into(), 1);
19059 state.inventory_hints.insert(
19060 "empty_bottle".into(),
19061 InventoryHint {
19062 display_name: "Glass Bottle".into(),
19063 category: "container".into(),
19064 ..Default::default()
19065 },
19066 );
19067
19068 let choices = state.loadout_hotbar_choices();
19069 assert!(choices.iter().any(|c| c.binding == "unarmed"));
19070 let water = choices
19071 .iter()
19072 .find(|c| c.binding == "item:empty_bottle")
19073 .expect("serving bottle binding");
19074 assert_eq!(water.meta.as_deref(), Some("use"));
19075 assert!(water.label.contains("Glass Bottle of Water"));
19076 assert_eq!(state.hotbar_slot_label(1), None, "unbound until set");
19077 state.hotbar = vec![None, None, None, None, Some("item:empty_bottle".into())];
19078 assert_eq!(
19079 state.hotbar_slot_label(5).as_deref(),
19080 Some("Glass Bottle×1")
19081 );
19082 }
19083
19084 #[test]
19085 fn loadout_hotbar_choices_exclude_blueprint_scrolls() {
19086 let mut state = sample_state();
19087 state.known_abilities = vec!["unarmed".into()];
19088 state.weapon_ability_id = "unarmed".into();
19089 state.inventory_stacks = vec![
19090 flatland_protocol::ItemStack {
19091 template_id: "carrot".into(),
19092 quantity: 2,
19093 display_name: Some("Wild Carrot".into()),
19094 category: Some("consumable".into()),
19095 ..Default::default()
19096 },
19097 flatland_protocol::ItemStack {
19098 template_id: "blueprint_dimensional_pouch".into(),
19099 quantity: 1,
19100 display_name: Some("Blueprint — Dimensional Pouch".into()),
19101 category: Some("consumable".into()),
19102 props: [("teaches_blueprint".into(), "craft_dimensional_pouch".into())]
19103 .into_iter()
19104 .collect(),
19105 ..Default::default()
19106 },
19107 ];
19108 state.inventory.insert("carrot".into(), 2);
19109 state.inventory.insert("blueprint_dimensional_pouch".into(), 1);
19110 state.inventory_hints.insert(
19111 "carrot".into(),
19112 InventoryHint {
19113 display_name: "Wild Carrot".into(),
19114 category: "consumable".into(),
19115 ..Default::default()
19116 },
19117 );
19118 state.inventory_hints.insert(
19119 "blueprint_dimensional_pouch".into(),
19120 InventoryHint {
19121 display_name: "Blueprint — Dimensional Pouch".into(),
19122 category: "consumable".into(),
19123 ..Default::default()
19124 },
19125 );
19126
19127 let choices = state.loadout_hotbar_choices();
19128 assert!(choices.iter().any(|c| c.binding == "item:carrot"));
19129 assert!(
19130 choices
19131 .iter()
19132 .all(|c| c.binding != "item:blueprint_dimensional_pouch"),
19133 "recipe scrolls must not appear on the hotbar picker: {choices:?}"
19134 );
19135 }
19136
19137 #[test]
19138 fn storage_store_options_excludes_hand_equipped() {
19139 let mut state = sample_state();
19140 let sword_id = uuid::Uuid::from_u128(11);
19141 let ore_id = uuid::Uuid::from_u128(22);
19142 state.inventory_stacks = vec![
19143 flatland_protocol::ItemStack {
19144 template_id: "short_sword".into(),
19145 quantity: 1,
19146 item_instance_id: Some(sword_id),
19147 display_name: Some("Short Sword".into()),
19148 category: Some("weapon".into()),
19149 ..Default::default()
19150 },
19151 flatland_protocol::ItemStack {
19152 template_id: "iron_ore".into(),
19153 quantity: 5,
19154 item_instance_id: Some(ore_id),
19155 display_name: Some("Iron Ore".into()),
19156 category: Some("resource".into()),
19157 ..Default::default()
19158 },
19159 ];
19160 state.mainhand_template_id = Some("short_sword".into());
19161 state.mainhand_instance_id = Some(sword_id);
19162
19163 let opts = state.storage_store_options();
19164 assert_eq!(opts.len(), 1);
19165 assert_eq!(opts[0].item_instance_id, ore_id);
19166 assert!(state.hand_equipped_instance_ids().contains(&sword_id));
19167 }
19168
19169 #[test]
19170 fn loose_consumable_move_picker_offers_use_and_storage() {
19171 let mut state = sample_state();
19172 let inst = uuid::Uuid::from_u128(77);
19173 state.inventory_stacks = vec![flatland_protocol::ItemStack {
19174 template_id: "carrot".into(),
19175 quantity: 2,
19176 item_instance_id: Some(inst),
19177 props: Default::default(),
19178 status_bindings: Vec::new(),
19179 contents: Vec::new(),
19180 display_name: Some("Wild Carrot".into()),
19181 category: Some("consumable".into()),
19182 base_mass: None,
19183 base_volume: None,
19184 capacity_volume: None,
19185 stackable: Some(true),
19186 world_placeable: None,
19187 worker_lodging_capacity: None,
19188 equip_slot: None,
19189 armor_physical: None,
19190 resists: vec![],
19191 hand_slots: None,
19192 listable: None,
19193 ..Default::default()
19194 }];
19195 state.inventory_hints.insert(
19196 "carrot".into(),
19197 InventoryHint {
19198 display_name: "Wild Carrot".into(),
19199 category: "consumable".into(),
19200 base_mass: Some(0.15),
19201 base_volume: Some(0.3),
19202 capacity_volume: None,
19203 stackable: true,
19204 listable: true,
19205 base_value_copper: None,
19206 },
19207 );
19208 state.show_inventory_menu = true;
19209 state.inventory_menu_index = 0;
19210
19211 let row = state.inventory_selected_row().expect("carrot row");
19212 let mut options = state.move_destinations_for(
19213 &row.from,
19214 row.from_parent_instance_id,
19215 row.stack.item_instance_id,
19216 &row.stack.template_id,
19217 );
19218 if row.from == flatland_protocol::InventoryLocation::Root
19219 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
19220 {
19221 options.insert(
19222 0,
19223 MoveOption {
19224 label: "Use (eat / drink)".into(),
19225 kind: MoveOptionKind::Use,
19226 },
19227 );
19228 }
19229
19230 assert_eq!(
19231 options.first().map(|o| &o.label),
19232 Some(&"Use (eat / drink)".into())
19233 );
19234 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
19235 assert!(options
19236 .iter()
19237 .any(|o| matches!(o.kind, MoveOptionKind::Drop)));
19238 }
19239
19240 #[test]
19241 fn inventory_category_group_order_is_stable() {
19242 assert_eq!(inventory_category_group("weapon").0, "Weapons");
19243 assert_eq!(inventory_category_group("armor").0, "Armor");
19244 assert_eq!(inventory_category_group("consumable").0, "Consumables");
19245 assert_eq!(inventory_category_group("liquid").0, "Consumables");
19246 assert_eq!(inventory_category_group("resource").0, "Resources");
19247 assert_eq!(inventory_category_group("container").0, "Containers");
19248 assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
19249 assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
19250 }
19251
19252 #[test]
19253 fn page_list_index_clamps_without_wrap() {
19254 assert_eq!(page_list_index(0, -1, 25), 0);
19255 assert_eq!(page_list_index(0, 1, 25), 10);
19256 assert_eq!(page_list_index(12, 1, 25), 22);
19257 assert_eq!(page_list_index(22, 1, 25), 24);
19258 assert_eq!(page_list_index(5, 1, 0), 0);
19259 assert_eq!(page_list_index(3, -1, 8), 0);
19260 }
19261
19262 #[test]
19263 fn inventory_filter_hides_non_matching_person_items() {
19264 let mut state = sample_state();
19265 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19266 sword.display_name = Some("Iron Sword".into());
19267 sword.category = Some("weapon".into());
19268 let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
19269 herb.display_name = Some("Wild Herb".into());
19270 herb.category = Some("consumable".into());
19271 state.inventory_stacks = vec![sword, herb];
19272 state.inventory_tab = InventoryTab::OnPerson;
19273 state.inventory_filter = "sword".into();
19274
19275 let rows = state.inventory_selectable_rows();
19276 assert_eq!(rows.len(), 1);
19277 assert_eq!(rows[0].stack.template_id, "iron_sword");
19278
19279 let lines = state.inventory_browser_lines();
19280 assert!(lines.iter().any(|l| matches!(
19281 l,
19282 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
19283 )));
19284 assert!(!lines.iter().any(|l| matches!(
19285 l,
19286 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
19287 )));
19288 }
19289
19290 #[test]
19291 fn list_filter_chars_reject_mac_arrow_glyphs() {
19292 assert!(is_list_filter_char('a'));
19293 assert!(is_list_filter_char(' '));
19294 assert!(is_list_filter_char('-'));
19295 assert!(!is_list_filter_char('\u{F700}'));
19296 assert!(!is_list_filter_char('\u{F701}'));
19297 assert!(!is_list_filter_char('\n'));
19298 }
19299
19300 #[test]
19301 fn ready_tab_keeps_in_progress_craft_after_inputs_spent() {
19302 let mut state = sample_state();
19303 state.craft_tab = CraftTab::Ready;
19304 state.blueprints = vec![BlueprintView {
19305 id: "plank".into(),
19306 label: "Plank".into(),
19307 craft_tier: 1,
19308 craft_ticks: 30,
19309 output: "wood_plank".into(),
19310 output_qty: 1,
19311 output_display_name: "Wood Plank".into(),
19312 station: None,
19313 category: None,
19314 inputs: vec![flatland_protocol::BlueprintIngredientView {
19315 template_id: "oak_log".into(),
19316 quantity: 1,
19317 consumed: true,
19318 display_name: "Oak Log".into(),
19319 }],
19320 required_tools: vec![],
19321 skill: None,
19322 failure_chance: 0.0,
19323 worker_train_copper: 0,
19324 }];
19325 state.inventory.clear();
19327 state.craft_channel_blueprint_id = Some("plank".into());
19328 state.timed_channel = Some(flatland_protocol::TimedChannelHud {
19329 label: "Crafting Plank".into(),
19330 channel: flatland_protocol::TimedChannelKind::Craft,
19331 ticks_remaining: 20,
19332 ticks_total: 30,
19333 ..Default::default()
19334 });
19335
19336 let idxs = state.craft_filtered_indices();
19337 assert_eq!(idxs, vec![0]);
19338 assert!(state.craft_blueprint_in_channel("plank"));
19339
19340 state.timed_channel = None;
19342 state.craft_channel_blueprint_id = None;
19343 assert!(state.craft_filtered_indices().is_empty());
19344 }
19345
19346 #[test]
19347 fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
19348 let mut state = sample_state();
19349 let id_a = uuid::Uuid::from_u128(0xa1);
19350 let id_b = uuid::Uuid::from_u128(0xb2);
19351 let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
19352 sword_a.display_name = Some("Iron Sword".into());
19353 sword_a.category = Some("weapon".into());
19354 sword_a.item_instance_id = Some(id_a);
19355 let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
19356 sword_b.display_name = Some("Iron Sword".into());
19357 sword_b.category = Some("weapon".into());
19358 sword_b.item_instance_id = Some(id_b);
19359 state.inventory_stacks = vec![sword_a, sword_b];
19360 state.inventory_tab = InventoryTab::OnPerson;
19361
19362 let lines = state.inventory_browser_lines();
19363 let items: Vec<_> = lines
19364 .iter()
19365 .filter_map(|l| match l {
19366 InventoryBrowserLine::Item {
19367 title,
19368 instance_tooltip,
19369 ..
19370 } => Some((title.clone(), instance_tooltip.clone())),
19371 _ => None,
19372 })
19373 .collect();
19374 assert_eq!(items.len(), 2);
19375 for (title, tip) in &items {
19376 assert!(
19377 !title.contains('#'),
19378 "title should not show instance suffix: {title}"
19379 );
19380 assert!(
19381 tip.is_some(),
19382 "two identical rows should expose instance on hover"
19383 );
19384 }
19385
19386 state.inventory_stacks.pop();
19387 let lines = state.inventory_browser_lines();
19388 let one = lines.iter().find_map(|l| match l {
19389 InventoryBrowserLine::Item {
19390 title,
19391 instance_tooltip,
19392 ..
19393 } => Some((title.clone(), instance_tooltip.clone())),
19394 _ => None,
19395 });
19396 let (title, tip) = one.expect("one sword row");
19397 assert!(!title.contains('#'));
19398 assert!(tip.is_none(), "single row should not need instance tooltip");
19399 }
19400
19401 #[test]
19402 fn inventory_person_rows_group_by_category() {
19403 let mut state = sample_state();
19404 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19405 sword.category = Some("weapon".into());
19406 sword.display_name = Some("Iron Sword".into());
19407 let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
19408 ore.category = Some("resource".into());
19409 ore.display_name = Some("Iron Ore".into());
19410 let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
19411 potion.category = Some("consumable".into());
19412 potion.display_name = Some("Health Potion".into());
19413 state.inventory_stacks = vec![ore, potion, sword];
19414 state.inventory_tab = InventoryTab::OnPerson;
19415
19416 let lines = state.inventory_browser_lines();
19417 let labels: Vec<&str> = lines
19418 .iter()
19419 .filter_map(|l| match l {
19420 InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
19421 _ => None,
19422 })
19423 .collect();
19424 assert!(
19425 labels.iter().any(|s| s.contains("Weapons")),
19426 "expected Weapons group: {labels:?}"
19427 );
19428 assert!(labels.iter().any(|s| s.contains("Consumables")));
19429 assert!(labels.iter().any(|s| s.contains("Resources")));
19430
19431 let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
19432 let consumable_pos = labels
19433 .iter()
19434 .position(|s| s.contains("Consumables"))
19435 .unwrap();
19436 let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
19437 assert!(weapon_pos < consumable_pos);
19438 assert!(consumable_pos < resource_pos);
19439 }
19440
19441 #[test]
19442 fn inventory_tab_cycle_resets_selection() {
19443 let mut state = sample_state();
19444 state.inventory_tab = InventoryTab::OnPerson;
19445 state.inventory_menu_index = 3;
19446 state.inventory_tab = state.inventory_tab.cycle(true);
19447 assert_eq!(state.inventory_tab, InventoryTab::Nearby);
19448 assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
19450 assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
19451 assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
19452 assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
19453 }
19454
19455 #[test]
19456 fn parse_bank_copper_amount_blank_and_zero_mean_all() {
19457 assert_eq!(parse_bank_copper_amount(""), Some(0));
19458 assert_eq!(parse_bank_copper_amount(" "), Some(0));
19459 assert_eq!(parse_bank_copper_amount("0"), Some(0));
19460 assert_eq!(parse_bank_copper_amount("250"), Some(250));
19461 assert_eq!(parse_bank_copper_amount("nope"), None);
19462 }
19463
19464 #[test]
19465 fn parse_storage_quantity_blank_and_zero_mean_all() {
19466 assert_eq!(parse_storage_quantity(""), Some(None));
19467 assert_eq!(parse_storage_quantity(" "), Some(None));
19468 assert_eq!(parse_storage_quantity("0"), Some(None));
19469 assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
19470 assert_eq!(parse_storage_quantity("nope"), None);
19471 }
19472
19473 #[test]
19474 fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
19475 assert!(worker_error_is_hud_noise("path stuck — repathing"));
19476 assert!(worker_error_is_hud_noise(
19477 "path stuck — nudged clear, repathing"
19478 ));
19479 assert!(worker_error_is_hud_noise(
19480 "returned to lodging after path failures"
19481 ));
19482 assert!(!worker_error_is_hud_noise(
19484 "path stuck — no lodging to reset to"
19485 ));
19486 assert!(!worker_error_is_hud_noise(
19487 "cannot reach Eli — idling"
19488 ));
19489 }
19490
19491 #[test]
19492 fn leaving_building_restores_outdoor_z_bands() {
19493 use flatland_protocol::{InteriorMapView, ZPlatformView};
19494
19495 let mut state = sample_state();
19496 state.z_platforms.clear();
19497 state.z_transitions.clear();
19498 state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
19499 state.interior_map = Some(InteriorMapView {
19500 building_id: "broker_hut".into(),
19501 blueprint_id: "broker_hut".into(),
19502 background_color: "#000".into(),
19503 default_floor_color: None,
19504 floor_height_m: 3.0,
19505 z_platforms: vec![ZPlatformView {
19506 id: "floor_0".into(),
19507 z: 0.0,
19508 x0: 0.0,
19509 y0: 0.0,
19510 x1: 8.0,
19511 y1: 8.0,
19512 }],
19513 z_transitions: vec![],
19514 rooms: vec![],
19515 room_doors: vec![],
19516 });
19517 state.sync_interior_map_context();
19518 assert_eq!(
19519 state.z_platforms.len(),
19520 1,
19521 "indoors installs interior platforms"
19522 );
19523 assert!(state.z_bands_outdoor_backup.is_some());
19524
19525 state.player.as_mut().unwrap().inside_building = None;
19526 state.sync_interior_map_context();
19527 assert!(
19528 state.z_platforms.is_empty(),
19529 "leaving must restore outdoor bands (empty), not leave interior platforms"
19530 );
19531 assert!(state.z_bands_outdoor_backup.is_none());
19532 assert!(state.interior_map.is_none());
19533 }
19534
19535 #[test]
19536 fn resource_node_route_label_prefers_friendly_label_with_suffix() {
19537 let node = ResourceNodeView {
19538 id: "crop-carrot-1_copy10".into(),
19539 label: "crop-carrot-1_copy10".into(),
19540 x: 0.0,
19541 y: 0.0,
19542 z: 0.0,
19543 item_template: "carrot".into(),
19544 state: ResourceNodeState::Available,
19545 blocking: false,
19546 blocking_radius_m: 0.5,
19547 harvest_off: false,
19548 tile_id: None,
19549 yaw: 0.0,
19550 pitch: 0.0,
19551 roll: 0.0,
19552 draw_scale: 1.0,
19553 sprite_mode: None,
19554 growth_progress: None,
19555 presentation_state: None,
19556 channel_start_tick: None,
19557 channel_end_tick: None,
19558 harvest_drop_templates: vec![],
19559 };
19560 let label = super::resource_node_route_label(&node);
19561 assert!(label.starts_with("Carrot ("), "got {label}");
19562 assert!(label.ends_with(')'), "got {label}");
19563
19564 let mut named = node;
19565 named.label = "Sweet Pad".into();
19566 named.id = "crop-carrot-a3f2b1c0".into();
19567 assert_eq!(super::resource_node_route_label(&named), "Sweet Pad (b1c0)");
19568 }
19569
19570 #[test]
19571 fn plot_public_label_uses_owner_zone_and_label() {
19572 let plot = flatland_protocol::PropertyPlotView {
19573 plot_id: uuid::Uuid::nil(),
19574 property_zone_id: "zone_a".into(),
19575 zone_label: Some("Starter Town East 1".into()),
19576 deed_instance_id: uuid::Uuid::nil(),
19577 x0: 0.0,
19578 y0: 0.0,
19579 x1: 4.0,
19580 y1: 4.0,
19581 upkeep_copper_per_day: 1,
19582 arrears_days: 0,
19583 is_mine: true,
19584 may_farm: true,
19585 purchase_basis_copper: 0,
19586 farm_public: false,
19587 public_tax_discount_bps: 0,
19588 farm_allow: vec![],
19589 owner_character_id: None,
19590 owner_label: Some("Madsin".into()),
19591 building_id: None,
19592 plot_code: "xyz1234a".into(),
19593 label: "Food Pad".into(),
19594 };
19595 assert_eq!(
19596 super::plot_public_label(&plot),
19597 "Madsin — Starter Town East 1 — Food Pad"
19598 );
19599 }
19600
19601 #[test]
19602 fn plot_public_label_uses_size_when_label_and_code_blank() {
19603 let plot = flatland_protocol::PropertyPlotView {
19604 plot_id: uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap(),
19605 property_zone_id: String::new(),
19606 zone_label: None,
19607 deed_instance_id: uuid::Uuid::nil(),
19608 x0: 10.0,
19609 y0: 20.0,
19610 x1: 18.0,
19611 y1: 28.0,
19612 upkeep_copper_per_day: 1,
19613 arrears_days: 0,
19614 is_mine: true,
19615 may_farm: true,
19616 purchase_basis_copper: 0,
19617 farm_public: false,
19618 public_tax_discount_bps: 0,
19619 farm_allow: vec![],
19620 owner_character_id: None,
19621 owner_label: None,
19622 building_id: None,
19623 plot_code: String::new(),
19624 label: String::new(),
19625 };
19626 assert_eq!(super::plot_public_label(&plot), "Homestead — Plot (8×8 m)");
19627 assert!(!super::plot_public_label(&plot).contains("19fe35f"));
19628 }
19629
19630 #[test]
19631 fn plot_stop_label_prefers_view_over_hex() {
19632 let plot_id = uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap();
19633 let plot = flatland_protocol::PropertyPlotView {
19634 plot_id,
19635 property_zone_id: "zone_a".into(),
19636 zone_label: Some("Starter Town East".into()),
19637 deed_instance_id: uuid::Uuid::nil(),
19638 x0: 0.0,
19639 y0: 0.0,
19640 x1: 4.0,
19641 y1: 4.0,
19642 upkeep_copper_per_day: 1,
19643 arrears_days: 0,
19644 is_mine: true,
19645 may_farm: true,
19646 purchase_basis_copper: 0,
19647 farm_public: false,
19648 public_tax_discount_bps: 0,
19649 farm_allow: vec![],
19650 owner_character_id: None,
19651 owner_label: Some("Madsin".into()),
19652 building_id: None,
19653 plot_code: "xyz1234a".into(),
19654 label: "Food Pad".into(),
19655 };
19656 assert_eq!(
19657 super::plot_stop_label(&[plot.clone()], plot_id),
19658 "Madsin — Starter Town East — Food Pad"
19659 );
19660 let missing = uuid::Uuid::parse_str("1a001590-0000-4000-8000-000000000002").unwrap();
19661 assert_eq!(super::plot_stop_label(&[plot], missing), "plot 1a001590");
19662 }
19663}