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("storage full") {
1235 return Some("empty or upgrade the destination chest, or reassign the deposit");
1236 }
1237 if e.contains("need a hoe") || e.contains("need a dibber") {
1238 return Some("give them the tool or withdraw it on the route");
1239 }
1240 None
1241}
1242
1243pub fn worker_error_is_transient(err: &str) -> bool {
1245 let e = err.to_ascii_lowercase();
1246 e.contains("continuing route") || e.starts_with("nothing to withdraw")
1247}
1248
1249pub fn worker_error_is_hud_noise(err: &str) -> bool {
1252 let e = err.to_ascii_lowercase();
1253 if e.contains("idling") && (e.contains("cannot reach") || e.contains("unreachable")) {
1255 return false;
1256 }
1257 e.contains("returned to lodging after path")
1258 || e.contains("path failure")
1259 || e.contains("no path to")
1260 || e.contains("pathfinding")
1261 || e.contains("repathing")
1263 || e.contains("nudged clear")
1264 || e.contains("auto-recovery")
1266 || e.contains("stranded at interior map coords")
1267}
1268
1269#[derive(Debug, Clone)]
1271pub struct PendingWorkerJobAck {
1272 pub seq: u32,
1273 pub worker_instance_id: String,
1274 pub worker_label: String,
1275 pub idle: bool,
1276 pub stop_count: usize,
1277 pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1278 pub prev_mode: flatland_protocol::WorkerModeView,
1279 pub prev_step_label: String,
1280 pub prev_last_error: Option<String>,
1281}
1282
1283fn push_inventory_rows(
1284 rows: &mut Vec<InventoryRow>,
1285 depth: usize,
1286 stack: &flatland_protocol::ItemStack,
1287 from: &flatland_protocol::InventoryLocation,
1288 from_parent_instance_id: Option<uuid::Uuid>,
1289 section: InventorySection,
1290) {
1291 push_inventory_rows_filtered(
1292 rows,
1293 depth,
1294 stack,
1295 from,
1296 from_parent_instance_id,
1297 section,
1298 "",
1299 );
1300}
1301
1302fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1303 if filter.is_empty() {
1304 return true;
1305 }
1306 let f = filter.to_ascii_lowercase();
1307 let name = stack
1308 .display_name
1309 .as_deref()
1310 .unwrap_or("")
1311 .to_ascii_lowercase();
1312 let tid = stack.template_id.to_ascii_lowercase();
1313 name.contains(&f)
1314 || tid.contains(&f)
1315 || stack
1316 .contents
1317 .iter()
1318 .any(|c| stack_matches_filter(c, filter))
1319}
1320
1321fn push_inventory_rows_filtered(
1322 rows: &mut Vec<InventoryRow>,
1323 depth: usize,
1324 stack: &flatland_protocol::ItemStack,
1325 from: &flatland_protocol::InventoryLocation,
1326 from_parent_instance_id: Option<uuid::Uuid>,
1327 section: InventorySection,
1328 filter: &str,
1329) {
1330 if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1331 return;
1332 }
1333 let self_hit = filter.is_empty() || {
1334 let f = filter.to_ascii_lowercase();
1335 let name = stack
1336 .display_name
1337 .as_deref()
1338 .unwrap_or("")
1339 .to_ascii_lowercase();
1340 let tid = stack.template_id.to_ascii_lowercase();
1341 name.contains(&f) || tid.contains(&f)
1342 };
1343 rows.push(InventoryRow {
1344 depth,
1345 stack: stack.clone(),
1346 from: from.clone(),
1347 from_parent_instance_id,
1348 is_equip_shell: false,
1349 is_chest_shell: false,
1350 section,
1351 });
1352 for child in &stack.contents {
1353 if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1354 push_inventory_rows_filtered(
1355 rows,
1356 depth + 1,
1357 child,
1358 from,
1359 stack.item_instance_id,
1360 section,
1361 if self_hit { "" } else { filter },
1362 );
1363 }
1364 }
1365}
1366
1367#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1368pub enum ShopTab {
1369 #[default]
1370 Buy,
1371 Sell,
1372}
1373
1374#[derive(Debug, Clone)]
1375pub struct NpcChatState {
1376 pub npc_id: String,
1377 pub npc_label: String,
1378 pub lines: Vec<String>,
1379 pub input: String,
1380 pub pending: bool,
1381 pub talk_depth: flatland_protocol::NpcTalkDepth,
1382 pub trade_allowed: bool,
1383 pub banner: Option<String>,
1384 pub suggested_topics: Vec<String>,
1385}
1386
1387impl Default for NpcChatState {
1388 fn default() -> Self {
1389 Self {
1390 npc_id: String::new(),
1391 npc_label: String::new(),
1392 lines: Vec::new(),
1393 input: String::new(),
1394 pending: false,
1395 talk_depth: flatland_protocol::NpcTalkDepth::Full,
1396 trade_allowed: true,
1397 banner: None,
1398 suggested_topics: Vec::new(),
1399 }
1400 }
1401}
1402
1403pub fn npc_world_xy(state: &GameState, npc: &NpcView) -> (f32, f32) {
1405 npc.entity_id
1406 .and_then(|eid| state.entities.iter().find(|e| e.id == eid))
1407 .map(|e| (e.transform.position.x, e.transform.position.y))
1408 .unwrap_or((npc.x, npc.y))
1409}
1410
1411#[derive(Debug, Clone)]
1412pub struct GameState {
1413 pub session_id: SessionId,
1414 pub entity_id: EntityId,
1415 pub character_id: Option<uuid::Uuid>,
1417 pub tick: Tick,
1418 pub chunk_rev: u64,
1419 pub content_rev: u64,
1420 pub publish_rev: u64,
1421 pub entities: Vec<EntityState>,
1422 pub player: Option<EntityState>,
1423 pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1424 pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1425 pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1426 pub buildings: Vec<BuildingView>,
1427 pub doors: Vec<DoorView>,
1428 pub interior_map: Option<InteriorMapView>,
1429 pub npcs: Vec<NpcView>,
1430 pub blueprints: Vec<BlueprintView>,
1431 pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1433 pub world_x0: f32,
1435 pub world_y0: f32,
1436 pub world_width_m: f32,
1437 pub world_height_m: f32,
1438 pub terrain_zones: Vec<TerrainZoneView>,
1439 pub z_platforms: Vec<ZPlatformView>,
1440 pub z_transitions: Vec<ZTransitionView>,
1441 #[doc(hidden)]
1444 pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1445 pub world_clock: flatland_protocol::WorldClock,
1446 pub inventory: std::collections::HashMap<String, u32>,
1447 pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1448 pub item_catalog: std::collections::HashMap<String, ItemCatalogEntryView>,
1450 pub logs: VecDeque<String>,
1451 pub intents_sent: u64,
1452 pub ticks_received: u64,
1453 pub connected: bool,
1454 pub disconnect_reason: Option<String>,
1455 pub show_stats: bool,
1456 pub hud_log_hidden: bool,
1458 pub show_equip_menu: bool,
1459 pub equip_menu_index: usize,
1460 pub show_craft_menu: bool,
1461 pub craft_menu_index: usize,
1462 pub craft_batch_quantity: u32,
1464 pub craft_tab: CraftTab,
1466 pub craft_filter: String,
1468 pub craft_filter_focused: bool,
1469 pub craft_prefs: crate::craft_prefs::CraftCharacterPrefs,
1471 pub show_plot_build_menu: bool,
1473 pub plot_build_focus_wall: bool,
1475 pub plot_build_wall_index: usize,
1476 pub plot_build_roof_index: usize,
1477 pub show_shop_menu: bool,
1478 pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1479 pub bank_panel: Option<flatland_protocol::BankPanel>,
1480 pub bank_menu_index: usize,
1481 pub bank_ui_mode: BankUiMode,
1482 pub storage_panel: Option<flatland_protocol::StoragePanel>,
1483 pub market_panel: Option<flatland_protocol::MarketPanel>,
1484 pub market_menu_index: usize,
1486 pub market_filter: String,
1488 pub market_filter_focused: bool,
1489 pub market_category_filter: Option<&'static str>,
1491 pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1493 pub market_ui_mode: MarketUiMode,
1494 pub storage_menu_index: usize,
1495 pub storage_ui_mode: StorageUiMode,
1496 pub shop_tab: ShopTab,
1497 pub shop_menu_index: usize,
1498 pub shop_quantity: u32,
1499 pub shop_trade_log: VecDeque<String>,
1501 pub show_npc_verb_menu: bool,
1502 pub npc_verb_target: Option<String>,
1503 pub npc_verb_index: usize,
1504 pub npc_verb_notice: Option<String>,
1506 pub player_verbs: crate::social::PlayerVerbState,
1508 pub social_chat: crate::social::SocialChatState,
1509 pub trade_ui: crate::social::TradeUiState,
1510 pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1511 pub show_npc_chat: bool,
1512 pub npc_chat: Option<NpcChatState>,
1513 pub show_inventory_menu: bool,
1514 pub inventory_menu_index: usize,
1515 pub inventory_tab: InventoryTab,
1516 pub inventory_filter: String,
1517 pub inventory_filter_focused: bool,
1518 pub show_move_picker: bool,
1519 pub move_picker_index: usize,
1520 pub move_picker: Option<MovePicker>,
1521 pub show_grant_picker: bool,
1522 pub grant_picker_index: usize,
1523 pub grant_picker: Option<GrantTargetPicker>,
1524 pub show_destroy_picker: bool,
1525 pub destroy_confirm_pending: bool,
1526 pub destroy_picker: Option<DestroyPicker>,
1527 pub show_rename_prompt: bool,
1529 pub rename_plot_id: Option<uuid::Uuid>,
1531 pub highlighted_plot_id: Option<uuid::Uuid>,
1533 pub show_worker_rename: bool,
1535 pub rename_buffer: String,
1536 pub combat_target: Option<EntityId>,
1538 pub combat_target_label: Option<String>,
1539 pub ground_target: Option<(f32, f32, f32)>,
1542 pub combat_fx: Vec<flatland_protocol::CombatFx>,
1544 pub ground_hazards: Vec<flatland_protocol::GroundHazardView>,
1546 pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1548 pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1550 pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1552 pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1554 pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1556 pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1558 pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1560 pub claim_mode: Option<ClaimModeState>,
1562 pub relocate_mode: Option<RelocateModeState>,
1564 pub sell_plot_confirm: Option<uuid::Uuid>,
1566 pub sell_plot_armed_at: Option<Instant>,
1568 pub show_plant_menu: bool,
1570 pub plant_menu_index: usize,
1571 pub show_farm_access: bool,
1573 pub farm_access_name_draft: String,
1575 pub farm_access_discount_bps: u32,
1577 pub farm_access_index: usize,
1579 pub plant_quantity: u32,
1580 pub in_combat: bool,
1581 pub auto_attack: bool,
1582 pub combat_has_los: bool,
1583 pub attack_cd_ticks: u64,
1584 pub gcd_ticks: u64,
1585 pub weapon_ability_id: String,
1586 pub mainhand_template_id: Option<String>,
1587 pub mainhand_label: Option<String>,
1588 pub mainhand_instance_id: Option<uuid::Uuid>,
1589 pub offhand_template_id: Option<String>,
1590 pub offhand_label: Option<String>,
1591 pub offhand_instance_id: Option<uuid::Uuid>,
1592 pub mainhand_hand_slots: u8,
1593 pub defense: Option<flatland_protocol::DefenseHud>,
1594 pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1596 pub carry_mass: f32,
1597 pub carry_mass_max: f32,
1598 pub encumbrance: flatland_protocol::EncumbranceState,
1599 pub move_speed_mps: f32,
1601 pub move_speed_mult: f32,
1603 pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1605 pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1607 pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1609 pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1611 pub combat_target_detail: Option<CombatTargetHud>,
1612 pub cast_progress: Option<CastProgressHud>,
1613 pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1615 pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1617 pub ability_cooldowns: Vec<AbilityCooldownHud>,
1618 pub blocking_active: bool,
1619 pub max_target_slots: u8,
1620 pub combat_slots: Vec<CombatSlotHud>,
1621 pub rotation_presets: Vec<RotationPreset>,
1622 pub known_abilities: Vec<String>,
1624 pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1626 pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1628 pub hotbar: Vec<Option<String>>,
1630 pub max_abilities_per_rotation: u8,
1632 pub show_loadout_menu: bool,
1633 pub show_keychain_menu: bool,
1634 pub keychain_menu_index: usize,
1635 pub show_rotation_editor: bool,
1636 pub loadout_menu_index: usize,
1638 pub loadout_hotbar_slot: u8,
1640 pub loadout_ability_index: usize,
1642 pub loadout_focus_presets: bool,
1644 pub rotation_editor: RotationEditorState,
1645 pub harvest_in_progress: bool,
1647 pub harvest_started_at: Option<Instant>,
1649 pub pending_craft_ack: Option<(u32, String, u32)>,
1651 pub craft_channel_blueprint_id: Option<String>,
1654 pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1655 pub interactables: Vec<flatland_protocol::InteractableView>,
1656 pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1657 pub career: Option<flatland_protocol::PlayerCareerView>,
1658 pub character_sheet_tab: CharacterSheetTab,
1659 pub ledger_period: LedgerPeriod,
1660 pub show_quest_offer: bool,
1661 pub pending_quest_offers: Vec<flatland_protocol::QuestOffer>,
1662 pub quest_offer_index: usize,
1663 pub show_quest_menu: bool,
1664 pub quest_menu_index: usize,
1665 pub quest_withdraw_confirm: bool,
1666 pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1667 pub show_workers_menu: bool,
1668 pub workers_menu_index: usize,
1669 pub worker_dismiss_confirmation: Option<WorkerDismissConfirmation>,
1670 pub workers_menu_compact: bool,
1672 pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1675 pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1677 pub worker_health_ring_until: BTreeMap<EntityId, Instant>,
1679 pub pending_worker_hire_since: Option<Instant>,
1681 pub show_worker_give_picker: bool,
1683 pub worker_give_picker_index: usize,
1684 pub worker_give_picker: Option<WorkerGivePicker>,
1685 pub show_worker_give_target_picker: bool,
1687 pub worker_give_target_picker_index: usize,
1688 pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1689 pub show_worker_take_picker: bool,
1691 pub worker_take_picker_index: usize,
1692 pub worker_take_picker: Option<WorkerTakePicker>,
1693 pub show_worker_teach_picker: bool,
1695 pub worker_teach_picker_index: usize,
1696 pub worker_teach_picker: Option<WorkerTeachPicker>,
1697 pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1699 pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1701 pub attending_worker_instance_id: Option<String>,
1703 pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1705}
1706
1707#[derive(Debug, Clone, PartialEq, Eq)]
1708pub enum NpcVerbAction {
1709 Talk,
1710 Trade,
1711 Bank,
1712 Storage,
1713 Market,
1714 QuestTalk { quest_id: String },
1715 QuestGive { quest_id: String },
1716}
1717
1718#[derive(Debug, Clone, PartialEq, Eq)]
1719pub struct NpcVerbChoice {
1720 pub label: String,
1721 pub action: NpcVerbAction,
1722}
1723
1724impl std::fmt::Display for NpcVerbChoice {
1725 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1726 f.write_str(&self.label)
1727 }
1728}
1729
1730impl GameState {
1731 pub fn selected_quest_offer(&self) -> Option<&flatland_protocol::QuestOffer> {
1732 self.pending_quest_offers.get(self.quest_offer_index)
1733 }
1734
1735 pub fn push_quest_offer(&mut self, offer: flatland_protocol::QuestOffer) {
1736 if self
1737 .pending_quest_offers
1738 .iter()
1739 .any(|existing| existing.quest_id == offer.quest_id)
1740 {
1741 self.show_quest_offer = true;
1742 return;
1743 }
1744 self.pending_quest_offers.push(offer);
1745 self.show_quest_offer = true;
1746 }
1747
1748 pub fn remove_quest_offer(&mut self, quest_id: &str) {
1749 self.pending_quest_offers
1750 .retain(|offer| offer.quest_id != quest_id);
1751 if self.pending_quest_offers.is_empty() {
1752 self.show_quest_offer = false;
1753 self.quest_offer_index = 0;
1754 return;
1755 }
1756 self.quest_offer_index = self
1757 .quest_offer_index
1758 .min(self.pending_quest_offers.len() - 1);
1759 self.show_quest_offer = true;
1760 }
1761
1762 pub fn clear_quest_offers(&mut self) {
1763 self.pending_quest_offers.clear();
1764 self.quest_offer_index = 0;
1765 self.show_quest_offer = false;
1766 }
1767
1768 pub fn move_quest_offer_selection(&mut self, delta: i32) {
1769 let n = self.pending_quest_offers.len();
1770 if n == 0 {
1771 self.quest_offer_index = 0;
1772 return;
1773 }
1774 let idx = self.quest_offer_index as i32;
1775 self.quest_offer_index = (idx + delta).rem_euclid(n as i32) as usize;
1776 }
1777
1778 pub fn push_log(&mut self, line: impl Into<String>) {
1779 self.logs.push_back(line.into());
1780 while self.logs.len() > MAX_LOG_LINES {
1781 self.logs.pop_front();
1782 }
1783 }
1784
1785 pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1786 self.shop_trade_log.push_back(line.into());
1787 while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1788 self.shop_trade_log.pop_front();
1789 }
1790 }
1791
1792 pub fn clear_shop_trade_log(&mut self) {
1793 self.shop_trade_log.clear();
1794 }
1795
1796 fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1797 if !self.show_shop_menu {
1798 return;
1799 }
1800 let msg = notice.message.trim();
1801 if msg.is_empty() {
1802 return;
1803 }
1804 if notice.coins_delta != 0
1805 || msg.starts_with("Bought ")
1806 || msg.starts_with("Sold ")
1807 || msg.contains("taught you how to craft")
1808 || msg.starts_with("need ")
1809 {
1810 self.push_shop_trade_log(msg);
1811 }
1812 }
1813
1814 pub fn is_alive(&self) -> bool {
1815 self.player
1816 .as_ref()
1817 .and_then(|p| p.vitals)
1818 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1819 .unwrap_or(true)
1820 }
1821
1822 pub fn push_audio(&mut self, cue: crate::social::AudioCue) {
1823 self.social_chat.push_cue(cue);
1824 }
1825
1826 fn sync_gameplay_audio(&mut self) {
1828 use crate::social::AudioCue;
1829 use flatland_protocol::PrimaryAttributes;
1830
1831 let alive = self.is_alive();
1832 let casting = self.cast_progress.is_some();
1833 let telegraph = self.focus_attack_telegraph_active();
1834 let in_aoe = self.player_inside_spatial_telegraph();
1835 let quest_sig = self.quest_audio_signature();
1836 let entity_id = self.entity_id;
1837 let char_level = self
1838 .player
1839 .as_ref()
1840 .and_then(|p| p.attributes)
1841 .map(|a| {
1842 PrimaryAttributes::display(a.strength)
1843 .saturating_add(PrimaryAttributes::display(a.dexterity))
1844 .saturating_add(PrimaryAttributes::display(a.intelligence))
1845 .saturating_add(PrimaryAttributes::display(a.stamina))
1846 .saturating_add(PrimaryAttributes::display(a.vitality))
1847 .saturating_add(PrimaryAttributes::display(a.wisdom))
1848 .saturating_add(PrimaryAttributes::display(a.charisma))
1849 })
1850 .unwrap_or(0);
1851
1852 let fx_ids: Vec<u64> = self.combat_fx.iter().map(|fx| fx.id).collect();
1853 let mut hit_cues = Vec::new();
1854 {
1855 let seen = &self.social_chat.audio_seen_fx_ids;
1856 for fx in &self.combat_fx {
1857 if seen.contains(&fx.id) {
1858 continue;
1859 }
1860 let Some(hit) = fx.hits.iter().find(|h| h.entity_id == entity_id) else {
1861 continue;
1862 };
1863 if hit.outcome == CombatFxHitOutcome::Blocked {
1864 hit_cues.push(AudioCue::CombatBlock);
1865 } else {
1866 let heavy = matches!(
1867 fx.kind,
1868 CombatFxKind::Sphere | CombatFxKind::Cone | CombatFxKind::Beam
1869 );
1870 hit_cues.push(if heavy {
1871 AudioCue::CombatHitHeavy
1872 } else {
1873 AudioCue::CombatHitLight
1874 });
1875 }
1876 }
1877 }
1878
1879 let audio = &mut self.social_chat;
1880 if !audio.audio_bootstrapped {
1881 audio.audio_was_alive = alive;
1882 audio.audio_was_casting = casting;
1883 audio.audio_had_target_telegraph = telegraph;
1884 audio.audio_was_in_aoe = in_aoe;
1885 audio.audio_quest_sig = quest_sig;
1886 audio.audio_char_level = char_level;
1887 audio.audio_seen_fx_ids = fx_ids;
1888 audio.audio_bootstrapped = true;
1889 return;
1890 }
1891
1892 if telegraph && !audio.audio_had_target_telegraph {
1893 audio.push_cue(AudioCue::CombatTelegraphStart);
1894 } else if !telegraph && audio.audio_had_target_telegraph {
1895 audio.push_cue(AudioCue::CombatTelegraphImpact);
1896 }
1897 audio.audio_had_target_telegraph = telegraph;
1898
1899 if in_aoe && !audio.audio_was_in_aoe {
1900 audio.push_cue(AudioCue::CombatAoeWarn);
1901 }
1902 audio.audio_was_in_aoe = in_aoe;
1903
1904 if casting && !audio.audio_was_casting {
1905 audio.push_cue(AudioCue::AbilityCastSelf);
1906 }
1907 audio.audio_was_casting = casting;
1908
1909 if !alive && audio.audio_was_alive {
1910 audio.push_cue(AudioCue::PlayerDeath);
1911 }
1912 audio.audio_was_alive = alive;
1913
1914 if quest_sig != audio.audio_quest_sig && audio.audio_quest_sig != 0 {
1915 audio.push_cue(AudioCue::QuestUpdate);
1916 }
1917 audio.audio_quest_sig = quest_sig;
1918
1919 if char_level > audio.audio_char_level && audio.audio_char_level > 0 {
1920 audio.push_cue(AudioCue::LevelUp);
1921 }
1922 audio.audio_char_level = char_level;
1923
1924 for cue in hit_cues {
1925 audio.push_cue(cue);
1926 }
1927 audio.audio_seen_fx_ids = fx_ids;
1928 }
1929
1930 fn focus_attack_telegraph_active(&self) -> bool {
1931 let Some(tid) = self.combat_target else {
1932 return false;
1933 };
1934 self.entities
1935 .iter()
1936 .find(|e| e.id == tid)
1937 .map(|e| {
1938 e.combat_cues.iter().any(|c| {
1939 matches!(c.kind, CombatCueKind::AttackTelegraph) && c.until_tick > self.tick
1940 })
1941 })
1942 .unwrap_or(false)
1943 }
1944
1945 fn player_inside_spatial_telegraph(&self) -> bool {
1946 let (px, py) = self.player_position();
1947 for e in &self.entities {
1948 for cue in &e.combat_cues {
1949 if !matches!(cue.kind, CombatCueKind::AttackTelegraph)
1950 || cue.until_tick <= self.tick
1951 {
1952 continue;
1953 }
1954 let Some(kind) = cue.telegraph_kind else {
1955 continue;
1956 };
1957 let (ox, oy) = match (cue.origin_x, cue.origin_y) {
1958 (Some(x), Some(y)) => (x, y),
1959 _ => continue,
1960 };
1961 match kind {
1962 CombatFxKind::Sphere => {
1963 let r = cue.radius_m.unwrap_or(1.0);
1964 let dx = px - ox;
1965 let dy = py - oy;
1966 if dx * dx + dy * dy <= r * r {
1967 return true;
1968 }
1969 }
1970 CombatFxKind::Cone | CombatFxKind::MeleeArc => {
1971 let reach = cue.reach_m.unwrap_or(2.0);
1972 let yaw = cue.yaw.unwrap_or(0.0);
1973 let arc = cue.arc_deg.unwrap_or(90.0).to_radians();
1974 let dx = px - ox;
1975 let dy = py - oy;
1976 let dist = (dx * dx + dy * dy).sqrt();
1977 if dist > reach || dist < 0.05 {
1978 continue;
1979 }
1980 let ang = dx.atan2(dy);
1981 let mut delta = ang - yaw;
1982 while delta > std::f32::consts::PI {
1983 delta -= std::f32::consts::TAU;
1984 }
1985 while delta < -std::f32::consts::PI {
1986 delta += std::f32::consts::TAU;
1987 }
1988 if delta.abs() <= arc * 0.5 {
1989 return true;
1990 }
1991 }
1992 _ => {}
1993 }
1994 }
1995 }
1996 false
1997 }
1998
1999 fn quest_audio_signature(&self) -> u64 {
2000 use std::collections::hash_map::DefaultHasher;
2001 use std::hash::{Hash, Hasher};
2002 let mut h = DefaultHasher::new();
2003 for q in &self.quest_log {
2004 q.quest_id.hash(&mut h);
2005 format!("{:?}", q.status).hash(&mut h);
2006 q.current_step_id.hash(&mut h);
2007 for o in &q.objectives {
2008 o.done.hash(&mut h);
2009 o.current.hash(&mut h);
2010 }
2011 }
2012 h.finish()
2013 }
2014
2015 pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
2017 let Some(ref id) = self.npc_verb_target else {
2018 return vec![];
2019 };
2020 let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
2021 return self.with_quest_verbs(id, vec![Self::talk_choice()]);
2022 };
2023 let role = npc.role.as_str();
2024 let rest = if Self::npc_role_is_bank(role) {
2025 vec![
2026 NpcVerbChoice {
2027 label: "Bank".into(),
2028 action: NpcVerbAction::Bank,
2029 },
2030 Self::talk_choice(),
2031 ]
2032 } else if Self::npc_role_is_storage(role) {
2033 vec![
2034 NpcVerbChoice {
2035 label: "Storage".into(),
2036 action: NpcVerbAction::Storage,
2037 },
2038 Self::talk_choice(),
2039 ]
2040 } else if Self::npc_role_is_market(role) {
2041 vec![
2042 NpcVerbChoice {
2043 label: "Market".into(),
2044 action: NpcVerbAction::Market,
2045 },
2046 Self::talk_choice(),
2047 ]
2048 } else if npc.can_trade || Self::npc_role_can_trade(role) {
2049 vec![
2050 Self::talk_choice(),
2051 NpcVerbChoice {
2052 label: "Trade".into(),
2053 action: NpcVerbAction::Trade,
2054 },
2055 ]
2056 } else {
2057 vec![Self::talk_choice()]
2058 };
2059 self.with_quest_verbs(id, rest)
2060 }
2061
2062 fn talk_choice() -> NpcVerbChoice {
2063 NpcVerbChoice {
2064 label: "Talk".into(),
2065 action: NpcVerbAction::Talk,
2066 }
2067 }
2068
2069 fn with_quest_verbs(&self, npc_id: &str, rest: Vec<NpcVerbChoice>) -> Vec<NpcVerbChoice> {
2070 let mut opts = self.quest_verb_choices(npc_id);
2071 opts.extend(rest);
2072 opts
2073 }
2074
2075 fn quest_verb_choices(&self, npc_id: &str) -> Vec<NpcVerbChoice> {
2076 if let Some(npc) = self.npcs.iter().find(|n| n.id == npc_id) {
2077 if !npc.quest_verbs.is_empty() {
2078 return npc
2079 .quest_verbs
2080 .iter()
2081 .map(|v| {
2082 let action = if v.kind == flatland_protocol::NpcQuestVerb::KIND_GIVE {
2083 NpcVerbAction::QuestGive {
2084 quest_id: v.quest_id.clone(),
2085 }
2086 } else {
2087 NpcVerbAction::QuestTalk {
2088 quest_id: v.quest_id.clone(),
2089 }
2090 };
2091 NpcVerbChoice {
2092 label: v.label.clone(),
2093 action,
2094 }
2095 })
2096 .collect();
2097 }
2098 }
2099 let catalog_ref = self.npc_quest_catalog_ref(npc_id);
2100 let mut opts = Vec::new();
2101 for q in &self.quest_log {
2102 if q.status != flatland_protocol::QuestStatusView::Active {
2103 continue;
2104 }
2105 let title = if q.title.trim().is_empty() {
2106 "Quest".to_string()
2107 } else {
2108 q.title.clone()
2109 };
2110 for o in &q.objectives {
2111 if o.done || o.npc_ref.as_deref() != Some(catalog_ref.as_str()) {
2112 continue;
2113 }
2114 if o.kind == "give_item" {
2115 opts.push(NpcVerbChoice {
2116 label: format!("Turn in: {title}"),
2117 action: NpcVerbAction::QuestGive {
2118 quest_id: q.quest_id.clone(),
2119 },
2120 });
2121 } else if o.kind == "talk_npc" {
2122 opts.push(NpcVerbChoice {
2123 label: title.clone(),
2124 action: NpcVerbAction::QuestTalk {
2125 quest_id: q.quest_id.clone(),
2126 },
2127 });
2128 }
2129 }
2130 }
2131 opts
2132 }
2133
2134 fn npc_quest_catalog_ref(&self, npc_id: &str) -> String {
2135 self.npcs
2136 .iter()
2137 .find(|n| n.id == npc_id)
2138 .and_then(|n| n.paperdoll_ref.clone())
2139 .unwrap_or_else(|| npc_id.to_string())
2140 }
2141
2142 fn count_inventory_template(&self, template: &str) -> u32 {
2143 self.inventory_stacks
2144 .iter()
2145 .filter(|s| s.template_id == template)
2146 .map(|s| s.quantity)
2147 .sum()
2148 }
2149
2150 fn npc_role_can_trade(role: &str) -> bool {
2151 matches!(role, "broker" | "cook" | "farmer" | "merchant")
2152 }
2153
2154 fn npc_role_is_bank(role: &str) -> bool {
2155 role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
2156 }
2157
2158 fn npc_role_is_storage(role: &str) -> bool {
2159 role.eq_ignore_ascii_case("storage_manager")
2160 }
2161
2162 fn npc_role_is_market(role: &str) -> bool {
2163 role.eq_ignore_ascii_case("market_clerk")
2164 }
2165
2166 pub fn bank_menu_options(&self) -> Vec<&'static str> {
2167 vec![
2168 "Deposit…",
2169 "Withdraw…",
2170 "Deposit all",
2171 "Withdraw all",
2172 "Transfer…",
2173 ]
2174 }
2175
2176 pub fn storage_menu_options(&self) -> Vec<String> {
2177 let mut opts = vec!["Store…".into(), "Take…".into()];
2178 if let Some(panel) = &self.storage_panel {
2179 for dest in &panel.ship_destinations {
2180 opts.push(format!(
2181 "Ship → {} ({} cp / {} ticks)",
2182 dest.label, dest.fee_copper, dest.travel_ticks
2183 ));
2184 }
2185 }
2186 opts
2187 }
2188
2189 pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
2193 let equipped = self.hand_equipped_instance_ids();
2194 self.person_rows()
2195 .into_iter()
2196 .filter(|r| r.depth == 0)
2197 .filter_map(|r| {
2198 let id = r.stack.item_instance_id?;
2199 if equipped.contains(&id) {
2200 return None;
2201 }
2202 Some(StoragePickOption {
2203 item_instance_id: id,
2204 template_id: r.stack.template_id.clone(),
2205 label: storage_stack_label(&r.stack),
2206 quantity: r.stack.quantity,
2207 category: r.stack.category.clone().unwrap_or_default(),
2208 })
2209 })
2210 .collect()
2211 }
2212
2213 pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
2215 let mut ids = std::collections::HashSet::new();
2216 if let Some(id) = self.mainhand_instance_id {
2217 ids.insert(id);
2218 } else if let Some(tid) = &self.mainhand_template_id {
2219 if let Some(id) = self
2220 .inventory_stacks
2221 .iter()
2222 .find(|s| &s.template_id == tid)
2223 .and_then(|s| s.item_instance_id)
2224 {
2225 ids.insert(id);
2226 }
2227 }
2228 if let Some(id) = self.offhand_instance_id {
2229 ids.insert(id);
2230 } else if let Some(tid) = &self.offhand_template_id {
2231 if let Some(id) = self
2232 .inventory_stacks
2233 .iter()
2234 .find(|s| {
2235 &s.template_id == tid
2236 && s.item_instance_id.is_some_and(|iid| !ids.contains(&iid))
2237 })
2238 .and_then(|s| s.item_instance_id)
2239 {
2240 ids.insert(id);
2241 }
2242 }
2243 ids
2244 }
2245
2246 pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
2248 let Some(panel) = &self.storage_panel else {
2249 return Vec::new();
2250 };
2251 panel
2252 .contents
2253 .iter()
2254 .filter_map(|s| {
2255 let id = s.item_instance_id?;
2256 Some(StoragePickOption {
2257 item_instance_id: id,
2258 template_id: s.template_id.clone(),
2259 label: storage_stack_label(s),
2260 quantity: s.quantity,
2261 category: s.category.clone().unwrap_or_default(),
2262 })
2263 })
2264 .collect()
2265 }
2266
2267 pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
2269 let mut opts = Vec::new();
2270 if !self
2271 .market_list_item_options(&MarketListSourceKind::Person)
2272 .is_empty()
2273 {
2274 opts.push((MarketListSourceKind::Person, "On person".into()));
2275 }
2276 if let Some(panel) = &self.market_panel {
2277 for vault in &panel.list_vaults {
2278 let source = MarketListSourceKind::TownStorage {
2279 building_id: vault.building_id.clone(),
2280 };
2281 if self.market_list_item_options(&source).is_empty() {
2282 continue;
2283 }
2284 let label = if vault.building_label.is_empty() {
2285 format!("Town storage ({})", vault.building_id)
2286 } else {
2287 format!("Town storage — {}", vault.building_label)
2288 };
2289 opts.push((source, label));
2290 }
2291 }
2292 opts
2293 }
2294
2295 pub fn market_list_item_options(
2297 &self,
2298 source: &MarketListSourceKind,
2299 ) -> Vec<StoragePickOption> {
2300 let filter = self.market_filter.as_str();
2301 let cat_filter = self.market_category_filter;
2302 let mut opts: Vec<StoragePickOption> = match source {
2303 MarketListSourceKind::Person => {
2304 let equipped = self.hand_equipped_instance_ids();
2305 self.person_rows()
2306 .into_iter()
2307 .filter(|r| r.depth == 0)
2308 .filter(|r| self.stack_is_market_listable(&r.stack))
2309 .filter_map(|r| {
2310 let id = r.stack.item_instance_id?;
2311 if equipped.contains(&id) {
2312 return None;
2313 }
2314 Some(StoragePickOption {
2315 item_instance_id: id,
2316 template_id: r.stack.template_id.clone(),
2317 label: storage_stack_label(&r.stack),
2318 quantity: r.stack.quantity,
2319 category: r
2320 .stack
2321 .category
2322 .clone()
2323 .or_else(|| {
2324 self.inventory_item_category(&r.stack.template_id)
2325 .map(str::to_string)
2326 })
2327 .unwrap_or_default(),
2328 })
2329 })
2330 .collect()
2331 }
2332 MarketListSourceKind::TownStorage { building_id } => {
2333 let Some(panel) = &self.market_panel else {
2334 return Vec::new();
2335 };
2336 let Some(vault) = panel
2337 .list_vaults
2338 .iter()
2339 .find(|v| &v.building_id == building_id)
2340 else {
2341 return Vec::new();
2342 };
2343 vault
2344 .contents
2345 .iter()
2346 .filter(|s| self.stack_is_market_listable(s))
2347 .filter_map(|s| {
2348 let id = s.item_instance_id?;
2349 Some(StoragePickOption {
2350 item_instance_id: id,
2351 template_id: s.template_id.clone(),
2352 label: storage_stack_label(s),
2353 quantity: s.quantity,
2354 category: s
2355 .category
2356 .clone()
2357 .or_else(|| {
2358 self.inventory_item_category(&s.template_id)
2359 .map(str::to_string)
2360 })
2361 .unwrap_or_default(),
2362 })
2363 })
2364 .collect()
2365 }
2366 };
2367 opts.retain(|o| {
2368 if !list_label_matches(&o.label, filter) {
2369 return false;
2370 }
2371 if let Some(group) = cat_filter {
2372 inventory_category_group(&o.category).0 == group
2373 } else {
2374 true
2375 }
2376 });
2377 opts
2378 }
2379
2380 pub fn item_base_value_copper_hint(&self, template_id: &str) -> Option<u32> {
2382 if let Some(hint) = self.inventory_hints.get(template_id) {
2383 if let Some(v) = hint.base_value_copper.filter(|v| *v > 0) {
2384 return Some(v);
2385 }
2386 }
2387 if let Some(v) = self
2388 .inventory_stacks
2389 .iter()
2390 .find(|s| s.template_id == template_id)
2391 .and_then(|s| s.base_value_copper.filter(|v| *v > 0))
2392 {
2393 return Some(v);
2394 }
2395 self.market_panel.as_ref().and_then(|panel| {
2396 panel.list_vaults.iter().find_map(|vault| {
2397 vault.contents.iter().find_map(|stack| {
2398 (stack.template_id == template_id)
2399 .then(|| stack.base_value_copper.filter(|v| *v > 0))
2400 .flatten()
2401 })
2402 })
2403 })
2404 }
2405
2406 pub fn npc_market_dump_unit_estimate(&self, template_id: &str) -> Option<u32> {
2408 let base = self.item_base_value_copper_hint(template_id)?;
2409 npc_market_dump_unit_estimate_copper(base)
2410 }
2411
2412 fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
2413 if crate::currency::is_currency(&stack.template_id) {
2414 return false;
2415 }
2416 if let Some(flag) = stack.listable {
2417 return flag;
2418 }
2419 if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
2420 return hint.listable;
2421 }
2422 let cat = stack
2423 .category
2424 .as_deref()
2425 .or_else(|| self.inventory_item_category(&stack.template_id))
2426 .unwrap_or("");
2427 category_default_listable(cat)
2428 }
2429
2430 pub fn market_available_category_groups(&self) -> Vec<&'static str> {
2432 let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
2433 match &self.market_ui_mode {
2434 MarketUiMode::ListPick { source, .. } => {
2435 let raw: Vec<_> = match source {
2436 MarketListSourceKind::Person => self
2437 .person_rows()
2438 .into_iter()
2439 .filter(|r| r.depth == 0)
2440 .filter(|r| self.stack_is_market_listable(&r.stack))
2441 .filter(|r| {
2442 list_label_matches(&storage_stack_label(&r.stack), &self.market_filter)
2443 })
2444 .map(|r| {
2445 r.stack
2446 .category
2447 .clone()
2448 .or_else(|| {
2449 self.inventory_item_category(&r.stack.template_id)
2450 .map(str::to_string)
2451 })
2452 .unwrap_or_default()
2453 })
2454 .collect(),
2455 MarketListSourceKind::TownStorage { building_id } => self
2456 .market_panel
2457 .as_ref()
2458 .and_then(|p| p.list_vaults.iter().find(|v| &v.building_id == building_id))
2459 .map(|vault| {
2460 vault
2461 .contents
2462 .iter()
2463 .filter(|s| self.stack_is_market_listable(s))
2464 .filter(|s| {
2465 list_label_matches(&storage_stack_label(s), &self.market_filter)
2466 })
2467 .map(|s| {
2468 s.category
2469 .clone()
2470 .or_else(|| {
2471 self.inventory_item_category(&s.template_id)
2472 .map(str::to_string)
2473 })
2474 .unwrap_or_default()
2475 })
2476 .collect::<Vec<_>>()
2477 })
2478 .unwrap_or_default(),
2479 };
2480 for category in raw {
2481 let (label, ord) = inventory_category_group(&category);
2482 seen.insert(ord, label);
2483 }
2484 }
2485 _ => {
2486 if let Some(panel) = &self.market_panel {
2487 for listing in &panel.listings {
2488 if !list_label_matches(&listing.display_name, &self.market_filter)
2489 && !list_label_matches(&listing.seller_label, &self.market_filter)
2490 {
2491 continue;
2492 }
2493 let (label, ord) = inventory_category_group(&listing.category);
2494 seen.insert(ord, label);
2495 }
2496 }
2497 }
2498 }
2499 seen.into_values().collect()
2500 }
2501
2502 pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
2504 let Some(panel) = &self.market_panel else {
2505 return Vec::new();
2506 };
2507 let filter = self.market_filter.as_str();
2508 let cat_filter = self.market_category_filter;
2509 panel
2510 .listings
2511 .iter()
2512 .enumerate()
2513 .filter(|(_, listing)| {
2514 if !list_label_matches(&listing.display_name, filter)
2515 && !list_label_matches(&listing.seller_label, filter)
2516 && !list_label_matches(&listing.template_id, filter)
2517 {
2518 return false;
2519 }
2520 if let Some(group) = cat_filter {
2521 inventory_category_group(&listing.category).0 == group
2522 } else {
2523 true
2524 }
2525 })
2526 .map(|(i, _)| i)
2527 .collect()
2528 }
2529
2530 pub fn clear_harvest_state(&mut self) {
2531 self.harvest_in_progress = false;
2532 self.harvest_started_at = None;
2533 }
2534
2535 fn harvest_state_stale(&self) -> bool {
2536 match self.harvest_started_at {
2537 Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
2538 None => self.harvest_in_progress,
2539 }
2540 }
2541
2542 pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
2543 self.player.as_ref().and_then(|p| p.vitals)
2544 }
2545
2546 pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
2547 let materials_ok = blueprint.inputs.iter().all(|input| {
2548 self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
2549 });
2550 let tools_ok = blueprint
2551 .required_tools
2552 .iter()
2553 .all(|tool| self.player_has_craft_tool(&tool.item));
2554 let station_ok = match blueprint.station.as_deref() {
2555 None | Some("hand") => true,
2556 Some(tag) => self.player_at_station_tag(tag),
2557 };
2558 materials_ok
2559 && tools_ok
2560 && station_ok
2561 && self.craft_has_vessel_room_for_output(blueprint)
2562 }
2563
2564 pub fn player_has_craft_tool(&self, tool_template: &str) -> bool {
2566 if self.inventory.get(tool_template).copied().unwrap_or(0) >= 1 {
2567 return true;
2568 }
2569 let Some(player) = self.player.as_ref() else {
2570 return false;
2571 };
2572 let px = player.transform.position.x;
2573 let py = player.transform.position.y;
2574 const RANGE: f32 = 3.0;
2576 self.placed_containers.iter().any(|c| {
2577 if c.template_id != tool_template {
2578 return false;
2579 }
2580 if !self.placed_container_in_current_space(c) {
2581 return false;
2582 }
2583 let dx = c.x - px;
2584 let dy = c.y - py;
2585 dx * dx + dy * dy <= RANGE * RANGE
2586 })
2587 }
2588
2589 fn craft_output_needs_vessel(&self, blueprint: &BlueprintView) -> bool {
2591 matches!(
2592 self.inventory_item_category(&blueprint.output),
2593 Some("bulk") | Some("liquid")
2594 ) || matches!(
2595 blueprint.output.as_str(),
2596 "dirt" | "mud" | "sand" | "water" | "milk"
2597 )
2598 }
2599
2600 fn craft_output_category(&self, blueprint: &BlueprintView) -> Option<&str> {
2601 self.inventory_item_category(&blueprint.output).or_else(|| {
2602 match blueprint.output.as_str() {
2603 "dirt" | "mud" | "sand" => Some("bulk"),
2604 "water" | "milk" => Some("liquid"),
2605 _ => None,
2606 }
2607 })
2608 }
2609
2610 fn craft_has_vessel_room_for_output(&self, blueprint: &BlueprintView) -> bool {
2611 if !self.craft_output_needs_vessel(blueprint) {
2612 return true;
2613 }
2614 let need = blueprint.output_qty.max(1);
2615 self.vessel_room_after_craft_inputs(blueprint) >= need
2616 }
2617
2618 fn vessel_room_after_craft_inputs(&self, blueprint: &BlueprintView) -> u32 {
2620 let mut stacks = self.inventory_stacks.clone();
2621 for worn in self.worn.values() {
2622 stacks.push(worn.clone());
2623 }
2624 for input in &blueprint.inputs {
2625 let mut left = input.quantity;
2626 drain_payload_from_stacks(&mut stacks, &input.template_id, &mut left);
2627 if left > 0 {
2628 return 0;
2629 }
2630 }
2631 vessel_room_for_payload_in_stacks(
2632 &stacks,
2633 &blueprint.output,
2634 self.craft_output_category(blueprint),
2635 )
2636 }
2637
2638 pub fn craft_vessel_status(&self, blueprint: &BlueprintView) -> CraftVesselStatus {
2640 let output_label = self.blueprint_output_label(blueprint);
2641 let needs_vessel = self.craft_output_needs_vessel(blueprint);
2642 let need_units = if needs_vessel {
2643 blueprint.output_qty.max(1)
2644 } else {
2645 0
2646 };
2647 let free_after_inputs = if needs_vessel {
2648 self.vessel_room_after_craft_inputs(blueprint)
2649 } else {
2650 0
2651 };
2652 let payload_cat = self.craft_output_category(blueprint);
2653 let mut vessels = Vec::new();
2654 Self::collect_craft_vessel_lines(
2655 &self.inventory_stacks,
2656 "pack",
2657 &blueprint.output,
2658 payload_cat,
2659 &mut vessels,
2660 );
2661 for worn in self.worn.values() {
2662 Self::collect_craft_vessel_lines(
2663 std::slice::from_ref(worn),
2664 "worn",
2665 &blueprint.output,
2666 payload_cat,
2667 &mut vessels,
2668 );
2669 }
2670 CraftVesselStatus {
2671 needs_vessel,
2672 output_label,
2673 need_units,
2674 free_after_inputs,
2675 ok: !needs_vessel || free_after_inputs >= need_units,
2676 vessels,
2677 }
2678 }
2679
2680 fn collect_craft_vessel_lines(
2681 stacks: &[flatland_protocol::ItemStack],
2682 location: &'static str,
2683 payload_id: &str,
2684 payload_category: Option<&str>,
2685 out: &mut Vec<CraftVesselLine>,
2686 ) {
2687 for stack in stacks {
2688 if is_serving_vessel_stack(stack) {
2689 let cap = serving_capacity_of(stack);
2690 let used = payload_units_in_vessel(stack);
2691 let free = vessel_free_room_for_payload(stack, payload_id, payload_category);
2692 let holds = stack
2693 .props
2694 .get("serving_holds")
2695 .cloned()
2696 .unwrap_or_else(|| {
2697 if stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
2698 && stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
2699 {
2700 "liquid,bulk".into()
2701 } else if stack.props.get("bulk_vessel").is_some_and(|v| v == "1") {
2702 "bulk".into()
2703 } else if stack.props.get("liquid_vessel").is_some_and(|v| v == "1") {
2704 "liquid".into()
2705 } else {
2706 "?".into()
2707 }
2708 });
2709 let label = stack
2710 .display_name
2711 .clone()
2712 .unwrap_or_else(|| stack.template_id.clone());
2713 out.push(CraftVesselLine {
2714 label,
2715 holds,
2716 capacity: cap,
2717 used,
2718 free,
2719 quantity: stack.quantity.max(1),
2720 accepts_output: free > 0,
2721 location,
2722 });
2723 }
2724 Self::collect_craft_vessel_lines(
2725 &stack.contents,
2726 location,
2727 payload_id,
2728 payload_category,
2729 out,
2730 );
2731 }
2732 }
2733
2734 fn craft_prefs_key(&self) -> String {
2735 if let Some(cid) = self.character_id {
2736 cid.to_string()
2737 } else if self.entity_id != 0 {
2738 format!("entity:{}", self.entity_id)
2739 } else {
2740 String::new()
2741 }
2742 }
2743
2744 pub fn reload_craft_prefs(&mut self) {
2745 let key = self.craft_prefs_key();
2746 self.craft_prefs = crate::craft_prefs::load_for_character(&key);
2747 }
2748
2749 fn persist_craft_prefs(&self) {
2750 crate::craft_prefs::save_for_character(&self.craft_prefs_key(), &self.craft_prefs);
2751 }
2752
2753 pub fn craft_known_tiers(&self) -> Vec<u32> {
2755 let mut tiers: Vec<u32> = self
2756 .blueprints
2757 .iter()
2758 .map(|bp| bp.craft_tier.max(1))
2759 .collect::<std::collections::BTreeSet<_>>()
2760 .into_iter()
2761 .collect();
2762 tiers.sort_unstable();
2763 tiers
2764 }
2765
2766 pub fn craft_tab_strip(&self) -> Vec<CraftTab> {
2768 let mut tabs = vec![CraftTab::Ready, CraftTab::Favorites, CraftTab::Recent];
2769 for t in self.craft_known_tiers() {
2770 tabs.push(CraftTab::Tier(t));
2771 }
2772 tabs
2773 }
2774
2775 pub fn craft_set_tab(&mut self, tab: CraftTab) {
2776 self.craft_tab = tab;
2777 self.craft_menu_index = 0;
2778 self.clamp_craft_menu_index();
2779 self.clamp_craft_batch_quantity();
2780 }
2781
2782 pub fn craft_cycle_tab(&mut self, delta: i32) {
2783 let tabs = self.craft_tab_strip();
2784 if tabs.is_empty() {
2785 return;
2786 }
2787 let cur = tabs
2788 .iter()
2789 .position(|t| *t == self.craft_tab)
2790 .unwrap_or(0) as i32;
2791 let next = (cur + delta).rem_euclid(tabs.len() as i32) as usize;
2792 self.craft_set_tab(tabs[next]);
2793 }
2794
2795 pub fn craft_matches_search(&self, bp: &BlueprintView) -> bool {
2796 let f = self.craft_filter.trim();
2797 if f.is_empty() {
2798 return true;
2799 }
2800 if list_label_matches(&bp.label, f)
2801 || list_label_matches(&bp.output, f)
2802 || list_label_matches(&bp.output_display_name, f)
2803 || bp
2804 .category
2805 .as_deref()
2806 .is_some_and(|c| list_label_matches(c, f))
2807 || bp
2808 .station
2809 .as_deref()
2810 .is_some_and(|s| list_label_matches(s, f))
2811 {
2812 return true;
2813 }
2814 bp.inputs.iter().any(|i| {
2815 list_label_matches(&i.template_id, f) || list_label_matches(&i.display_name, f)
2816 }) || bp.required_tools.iter().any(|t| {
2817 list_label_matches(&t.item, f) || list_label_matches(&t.display_name, f)
2818 })
2819 }
2820
2821 pub fn craft_blueprint_in_channel(&self, blueprint_id: &str) -> bool {
2823 self.craft_channel_blueprint_id.as_deref() == Some(blueprint_id)
2824 && self.active_craft_channel().is_some()
2825 }
2826
2827 pub fn craft_filtered_indices(&self) -> Vec<usize> {
2829 let mut idxs: Vec<usize> = (0..self.blueprints.len())
2830 .filter(|&i| {
2831 let bp = &self.blueprints[i];
2832 if !self.craft_matches_search(bp) {
2833 return false;
2834 }
2835 match self.craft_tab {
2836 CraftTab::Ready => {
2837 self.can_craft_blueprint(bp) || self.craft_blueprint_in_channel(&bp.id)
2838 }
2839 CraftTab::Favorites => self.craft_prefs.is_favorite(&bp.id),
2840 CraftTab::Recent => self.craft_prefs.recent.iter().any(|id| id == &bp.id),
2841 CraftTab::Tier(t) => bp.craft_tier.max(1) == t,
2842 }
2843 })
2844 .collect();
2845 match self.craft_tab {
2846 CraftTab::Recent => {
2847 idxs.sort_by_key(|&i| {
2848 self.craft_prefs
2849 .recent
2850 .iter()
2851 .position(|id| id == &self.blueprints[i].id)
2852 .unwrap_or(usize::MAX)
2853 });
2854 }
2855 _ => {
2856 idxs.sort_by(|&a, &b| {
2857 let ba = &self.blueprints[a];
2858 let bb = &self.blueprints[b];
2859 let ia = self.craft_blueprint_in_channel(&ba.id);
2860 let ib = self.craft_blueprint_in_channel(&bb.id);
2861 ib.cmp(&ia)
2863 .then_with(|| {
2864 let ra = self.can_craft_blueprint(ba);
2865 let rb = self.can_craft_blueprint(bb);
2866 rb.cmp(&ra)
2867 })
2868 .then_with(|| ba.label.to_ascii_lowercase().cmp(&bb.label.to_ascii_lowercase()))
2869 });
2870 }
2871 }
2872 idxs
2873 }
2874
2875 pub fn craft_selected_blueprint(&self) -> Option<&BlueprintView> {
2876 let idxs = self.craft_filtered_indices();
2877 idxs.get(self.craft_menu_index)
2878 .and_then(|&i| self.blueprints.get(i))
2879 }
2880
2881 pub fn clamp_craft_menu_index(&mut self) {
2882 let n = self.craft_filtered_indices().len();
2883 if n == 0 {
2884 self.craft_menu_index = 0;
2885 } else {
2886 self.craft_menu_index = self.craft_menu_index.min(n - 1);
2887 }
2888 }
2889
2890 pub fn craft_is_favorite(&self, blueprint_id: &str) -> bool {
2891 self.craft_prefs.is_favorite(blueprint_id)
2892 }
2893
2894 pub fn craft_toggle_favorite_selected(&mut self) {
2895 let Some(id) = self.craft_selected_blueprint().map(|bp| bp.id.clone()) else {
2896 return;
2897 };
2898 self.craft_prefs.toggle_favorite(&id);
2899 self.persist_craft_prefs();
2900 if matches!(self.craft_tab, CraftTab::Favorites) {
2901 self.clamp_craft_menu_index();
2902 }
2903 }
2904
2905 pub fn craft_record_completed(&mut self, blueprint_id: &str) {
2906 self.craft_prefs.record_crafted(blueprint_id);
2907 self.persist_craft_prefs();
2908 }
2909
2910 pub fn focus_craft_filter(&mut self) {
2911 self.craft_filter_focused = true;
2912 }
2913
2914 pub fn append_craft_filter_char(&mut self, ch: char) {
2915 if !self.craft_filter_focused {
2916 return;
2917 }
2918 if is_list_filter_char(ch) {
2919 self.craft_filter.push(ch);
2920 self.craft_menu_index = 0;
2921 self.clamp_craft_menu_index();
2922 }
2923 }
2924
2925 pub fn craft_filter_backspace(&mut self) {
2926 if !self.craft_filter_focused {
2927 return;
2928 }
2929 self.craft_filter.pop();
2930 self.craft_menu_index = 0;
2931 self.clamp_craft_menu_index();
2932 }
2933
2934 pub fn clear_or_blur_craft_filter(&mut self) -> bool {
2936 if self.craft_filter_focused {
2937 if !self.craft_filter.is_empty() {
2938 self.craft_filter.clear();
2939 self.craft_menu_index = 0;
2940 self.clamp_craft_menu_index();
2941 } else {
2942 self.craft_filter_focused = false;
2943 }
2944 return true;
2945 }
2946 if !self.craft_filter.is_empty() {
2947 self.craft_filter.clear();
2948 self.craft_menu_index = 0;
2949 self.clamp_craft_menu_index();
2950 return true;
2951 }
2952 false
2953 }
2954
2955 pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
2956 if !self.can_craft_blueprint(blueprint) {
2957 return 0;
2958 }
2959 let mut limit = u32::MAX;
2960 for input in &blueprint.inputs {
2961 if input.quantity == 0 {
2962 continue;
2963 }
2964 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2965 limit = limit.min(have / input.quantity);
2966 }
2967 for tool in &blueprint.required_tools {
2968 if tool.consumed {
2969 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2970 limit = limit.min(have);
2971 }
2972 }
2973 let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
2974 if CRAFT_STAMINA_COST > 0.0 {
2975 limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
2976 }
2977 if self.craft_output_needs_vessel(blueprint) {
2978 let need = blueprint.output_qty.max(1);
2979 let room = self.vessel_room_after_craft_inputs(blueprint);
2980 if need > 0 {
2981 limit = limit.min(room / need);
2982 }
2983 }
2984 limit
2985 }
2986
2987 pub fn clamp_craft_batch_quantity(&mut self) {
2988 let Some(bp) = self.craft_selected_blueprint().cloned() else {
2989 self.craft_batch_quantity = 1;
2990 return;
2991 };
2992 let max = self.max_craft_batches(&bp).max(1);
2993 self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
2994 }
2995
2996 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
2997 let Some(bp) = self.craft_selected_blueprint().cloned() else {
2998 return;
2999 };
3000 let max = self.max_craft_batches(&bp).max(1);
3001 let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
3002 self.craft_batch_quantity = next as u32;
3003 }
3004
3005 pub fn craft_batch_set_max(&mut self) {
3006 let Some(bp) = self.craft_selected_blueprint().cloned() else {
3007 return;
3008 };
3009 let max = self.max_craft_batches(&bp);
3010 self.craft_batch_quantity = if max == 0 { 1 } else { max };
3011 }
3012
3013 pub fn craft_batch_set_min(&mut self) {
3014 self.craft_batch_quantity = 1;
3015 }
3016
3017 pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
3018 let preserve_ui = self.show_shop_menu;
3019 let tab = self.shop_tab;
3020 let index = self.shop_menu_index;
3021 let qty = self.shop_quantity;
3022
3023 self.show_shop_menu = true;
3024 self.bank_panel = None;
3025 self.show_craft_menu = false;
3026 self.show_inventory_menu = false;
3027 self.show_stats = false;
3028 if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
3029 self.npc_verb_target = Some(catalog.npc_id.clone());
3030 }
3031 self.shop_catalog = Some(catalog);
3032
3033 if preserve_ui {
3034 self.shop_tab = tab;
3035 self.shop_menu_index = index;
3036 self.shop_quantity = qty;
3037 } else {
3038 self.shop_tab = ShopTab::Buy;
3039 self.shop_menu_index = 0;
3040 self.shop_quantity = 1;
3041 self.clear_shop_trade_log();
3042 }
3043 self.show_npc_verb_menu = false;
3044 self.clamp_shop_selection();
3045 }
3046
3047 pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
3048 let same_teller = self
3049 .bank_panel
3050 .as_ref()
3051 .is_some_and(|p| p.npc_id == panel.npc_id);
3052 self.bank_panel = Some(panel);
3053 self.storage_panel = None;
3054 self.market_panel = None;
3055 self.shop_catalog = None;
3056 self.show_shop_menu = false;
3057 self.show_craft_menu = false;
3058 self.show_inventory_menu = false;
3059 self.show_stats = false;
3060 self.show_npc_verb_menu = false;
3061 self.show_npc_chat = false;
3062 self.npc_chat = None;
3063 if !same_teller {
3064 self.bank_menu_index = 0;
3065 self.bank_ui_mode = BankUiMode::Menu;
3066 }
3067 if let Some(panel) = &self.bank_panel {
3068 if self.npc_verb_target.is_none() {
3069 self.npc_verb_target = Some(panel.npc_id.clone());
3070 }
3071 }
3072 }
3073
3074 pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
3075 let same_manager = self
3076 .storage_panel
3077 .as_ref()
3078 .is_some_and(|p| p.npc_id == panel.npc_id);
3079 self.storage_panel = Some(panel);
3080 self.bank_panel = None;
3081 self.market_panel = None;
3082 self.bank_ui_mode = BankUiMode::Menu;
3083 self.shop_catalog = None;
3084 self.show_shop_menu = false;
3085 self.show_craft_menu = false;
3086 self.show_inventory_menu = false;
3087 self.show_stats = false;
3088 self.show_npc_verb_menu = false;
3089 self.show_npc_chat = false;
3090 self.npc_chat = None;
3091 if !same_manager {
3092 self.storage_menu_index = 0;
3093 self.storage_ui_mode = StorageUiMode::Menu;
3094 } else {
3095 self.clamp_storage_pick_index();
3096 }
3097 if let Some(panel) = &self.storage_panel {
3098 if self.npc_verb_target.is_none() {
3099 self.npc_verb_target = Some(panel.npc_id.clone());
3100 }
3101 }
3102 }
3103
3104 pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
3105 for vault in &panel.list_vaults {
3106 self.merge_stack_catalog_hints(&vault.contents);
3107 }
3108 self.market_panel = Some(panel);
3109 self.bank_panel = None;
3110 self.storage_panel = None;
3111 self.shop_catalog = None;
3112 self.show_shop_menu = false;
3113 self.show_craft_menu = false;
3114 self.show_inventory_menu = false;
3115 self.show_stats = false;
3116 self.show_npc_verb_menu = false;
3117 self.show_npc_chat = false;
3118 self.npc_chat = None;
3119 self.market_menu_index = 0;
3120 self.market_buy_confirm = None;
3121 self.market_ui_mode = MarketUiMode::Browse;
3122 self.market_filter.clear();
3123 self.market_filter_focused = false;
3124 self.market_category_filter = None;
3125 if let Some(panel) = &self.market_panel {
3126 if self.npc_verb_target.is_none() {
3127 self.npc_verb_target = Some(panel.npc_id.clone());
3128 }
3129 }
3130 }
3131
3132 pub fn clear_market_panel(&mut self) {
3133 self.market_panel = None;
3134 self.market_menu_index = 0;
3135 self.market_buy_confirm = None;
3136 self.market_ui_mode = MarketUiMode::Browse;
3137 self.market_filter.clear();
3138 self.market_filter_focused = false;
3139 self.market_category_filter = None;
3140 }
3141
3142 pub fn clear_bank_panel(&mut self) {
3143 self.bank_panel = None;
3144 self.bank_menu_index = 0;
3145 self.bank_ui_mode = BankUiMode::Menu;
3146 }
3147
3148 pub fn clear_storage_panel(&mut self) {
3149 self.storage_panel = None;
3150 self.storage_menu_index = 0;
3151 self.storage_ui_mode = StorageUiMode::Menu;
3152 }
3153
3154 fn clamp_storage_pick_index(&mut self) {
3155 match &self.storage_ui_mode {
3156 StorageUiMode::StorePick { index } => {
3157 let n = self.storage_store_options().len();
3158 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3159 self.storage_ui_mode = StorageUiMode::StorePick { index: next };
3160 }
3161 StorageUiMode::TakePick { index } => {
3162 let n = self.storage_vault_options().len();
3163 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3164 self.storage_ui_mode = StorageUiMode::TakePick { index: next };
3165 }
3166 StorageUiMode::ShipPick {
3167 dest_building_id,
3168 dest_label,
3169 index,
3170 } => {
3171 let n = self.storage_vault_options().len();
3172 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3173 self.storage_ui_mode = StorageUiMode::ShipPick {
3174 dest_building_id: dest_building_id.clone(),
3175 dest_label: dest_label.clone(),
3176 index: next,
3177 };
3178 }
3179 StorageUiMode::Menu
3180 | StorageUiMode::StoreAmount { .. }
3181 | StorageUiMode::TakeAmount { .. }
3182 | StorageUiMode::ShipAmount { .. } => {}
3183 }
3184 }
3185
3186 pub fn shop_list_len(&self) -> usize {
3187 let Some(catalog) = &self.shop_catalog else {
3188 return 0;
3189 };
3190 match self.shop_tab {
3191 ShopTab::Buy => catalog.sells.len(),
3192 ShopTab::Sell => catalog.buys.len(),
3193 }
3194 }
3195
3196 pub fn shop_menu_move(&mut self, delta: i32) {
3197 let n = self.shop_list_len();
3198 if n == 0 {
3199 return;
3200 }
3201 let idx = self.shop_menu_index as i32;
3202 let next = (idx + delta).rem_euclid(n as i32);
3203 self.shop_menu_index = next as usize;
3204 self.clamp_shop_quantity();
3205 }
3206
3207 pub fn shop_quantity_adjust(&mut self, delta: i32) {
3208 let max = self.shop_quantity_max();
3209 if max == 0 {
3210 self.shop_quantity = 0;
3211 return;
3212 }
3213 let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
3214 self.shop_quantity = next as u32;
3215 }
3216
3217 pub(crate) fn clamp_shop_selection(&mut self) {
3218 let n = self.shop_list_len();
3219 if n == 0 {
3220 self.shop_menu_index = 0;
3221 } else {
3222 self.shop_menu_index = self.shop_menu_index.min(n - 1);
3223 }
3224 self.clamp_shop_quantity();
3225 }
3226
3227 fn shop_quantity_max(&self) -> u32 {
3228 let Some(catalog) = &self.shop_catalog else {
3229 return 1;
3230 };
3231 match self.shop_tab {
3232 ShopTab::Buy => {
3233 if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
3234 if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
3235 return 1;
3236 }
3237 }
3238 99
3239 }
3240 ShopTab::Sell => catalog
3241 .buys
3242 .get(self.shop_menu_index)
3243 .map(|l| l.quantity)
3244 .unwrap_or(0),
3245 }
3246 }
3247
3248 pub fn shop_quantity_set_max(&mut self) {
3249 self.shop_quantity = self.shop_quantity_max();
3250 }
3251
3252 pub fn shop_quantity_set_min(&mut self) {
3253 let max = self.shop_quantity_max();
3254 self.shop_quantity = if max == 0 { 0 } else { 1 };
3255 }
3256
3257 fn clamp_shop_quantity(&mut self) {
3258 let max = self.shop_quantity_max();
3259 if max == 0 {
3260 self.shop_quantity = 0;
3261 } else {
3262 self.shop_quantity = self.shop_quantity.max(1).min(max);
3263 }
3264 }
3265
3266 pub fn player_at_station_tag(&self, tag: &str) -> bool {
3267 let Some(id) = self.effective_inside_building() else {
3268 return false;
3269 };
3270 self.buildings
3271 .iter()
3272 .find(|b| b.id == id)
3273 .is_some_and(|b| b.tags.iter().any(|t| t == tag))
3274 }
3275
3276 pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
3278 if self.can_craft_blueprint(blueprint) {
3279 return None;
3280 }
3281 let mut missing = Vec::new();
3282 for input in &blueprint.inputs {
3283 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
3284 if have < input.quantity {
3285 let name = self.blueprint_ingredient_label(input);
3286 let vessel_note = if self.inventory_item_category(&input.template_id)
3287 == Some("liquid")
3288 || matches!(input.template_id.as_str(), "water" | "milk")
3289 {
3290 "; fill a bottle/waterskin"
3291 } else if self.inventory_item_category(&input.template_id) == Some("bulk")
3292 || matches!(input.template_id.as_str(), "dirt" | "mud" | "sand")
3293 {
3294 "; scoop into a sack/bucket"
3295 } else {
3296 ""
3297 };
3298 missing.push(format!(
3299 "{}×{} (have {have}{vessel_note})",
3300 input.quantity, name
3301 ));
3302 }
3303 }
3304 for tool in &blueprint.required_tools {
3305 if !self.player_has_craft_tool(&tool.item) {
3306 missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
3307 }
3308 }
3309 if let Some(station) = blueprint.station.as_deref() {
3310 if station != "hand" && !self.player_at_station_tag(station) {
3311 missing.push(format!("station: {station} (enter building)"));
3312 }
3313 }
3314 if self.craft_output_needs_vessel(blueprint) && !self.craft_has_vessel_room_for_output(blueprint)
3315 {
3316 let name = self
3317 .inventory_hints
3318 .get(&blueprint.output)
3319 .map(|h| h.display_name.as_str())
3320 .unwrap_or(blueprint.output.as_str());
3321 let need = blueprint.output_qty.max(1);
3322 let free = self.vessel_room_after_craft_inputs(blueprint);
3323 let accepting = self
3324 .craft_vessel_status(blueprint)
3325 .vessels
3326 .iter()
3327 .filter(|v| v.accepts_output)
3328 .count();
3329 missing.push(format!(
3330 "vessel room for {name}: need {need} free after inputs, have {free} ({accepting} accepting vessel(s))"
3331 ));
3332 }
3333 if missing.is_empty() {
3334 None
3335 } else {
3336 Some(missing.join(", "))
3337 }
3338 }
3339
3340 pub fn active_craft_channel(&self) -> Option<&flatland_protocol::TimedChannelHud> {
3342 self.timed_channel
3343 .as_ref()
3344 .filter(|c| c.channel == flatland_protocol::TimedChannelKind::Craft)
3345 }
3346
3347 pub fn player_entity(&self) -> Option<&EntityState> {
3348 self.player
3349 .as_ref()
3350 .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
3351 }
3352
3353 pub fn apply_client_ui_prefs(&mut self) {
3355 let cfg = crate::client_config::ClientConfig::load();
3356 if let Some(hidden) = cfg.hud_log_hidden {
3357 self.hud_log_hidden = hidden;
3358 }
3359 if let Some(compact) = cfg.workers_menu_compact {
3360 self.workers_menu_compact = compact;
3361 }
3362 }
3363
3364 pub fn player_position(&self) -> (f32, f32) {
3365 let (x, y, _) = self.player_position_with_z();
3366 (x, y)
3367 }
3368
3369 pub fn player_position_with_z(&self) -> (f32, f32, f32) {
3370 if let Some(p) = self.player_entity() {
3371 (
3372 p.transform.position.x,
3373 p.transform.position.y,
3374 p.transform.position.z,
3375 )
3376 } else {
3377 (0.0, 0.0, 0.0)
3378 }
3379 }
3380
3381 pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
3382 let mut rows: Vec<(String, u32, String)> = self
3383 .inventory
3384 .iter()
3385 .filter(|(_, q)| **q > 0)
3386 .map(|(id, qty)| {
3387 let label = self
3388 .inventory_hints
3389 .get(id)
3390 .map(|h| h.display_name.clone())
3391 .unwrap_or_else(|| id.clone());
3392 (id.clone(), *qty, label)
3393 })
3394 .collect();
3395 rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
3396 rows
3397 }
3398
3399 pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
3400 self.inventory_hints
3401 .get(template_id)
3402 .map(|h| h.category.as_str())
3403 .filter(|c| !c.is_empty())
3404 }
3405
3406 pub fn stack_is_serving(stack: &flatland_protocol::ItemStack) -> bool {
3407 stack.props.get("serving").is_some_and(|v| v == "1")
3408 || Self::stack_is_liquid_vessel(stack)
3409 }
3410
3411 pub fn stack_is_liquid_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3412 stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
3413 || stack.props.get("serving_holds").is_some_and(|v| {
3414 v.split(',').any(|p| p.trim() == "liquid")
3415 })
3416 }
3417
3418 pub fn stack_is_food_serving(stack: &flatland_protocol::ItemStack) -> bool {
3419 stack.props.get("serving_holds").is_some_and(|v| {
3420 v.split(',').any(|p| p.trim() == "food")
3421 })
3422 }
3423
3424 pub fn stack_is_bulk_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3425 stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
3426 || stack.props.get("serving_holds").is_some_and(|v| {
3427 v.split(',').any(|p| p.trim() == "bulk")
3428 })
3429 }
3430
3431 pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
3432 stack
3433 .props
3434 .get("grants_item_status_effect")
3435 .map(|s| !s.is_empty())
3436 .unwrap_or(false)
3437 }
3438
3439 pub fn stack_is_blueprint_scroll(stack: &flatland_protocol::ItemStack) -> bool {
3440 stack
3441 .props
3442 .get("teaches_blueprint")
3443 .map(|s| !s.trim().is_empty())
3444 .unwrap_or(false)
3445 }
3446
3447 pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
3448 stack
3449 .props
3450 .get("grants_item_status_effect")
3451 .map(String::as_str)
3452 .filter(|s| !s.is_empty())
3453 }
3454
3455 pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
3456 stack
3457 .props
3458 .get("grants_item_status_mode")
3459 .map(String::as_str)
3460 .unwrap_or("on_hit")
3461 }
3462
3463 pub fn grant_target_options(
3465 &self,
3466 grant: &flatland_protocol::ItemStack,
3467 ) -> Vec<GrantTargetOption> {
3468 let mode = Self::grant_mode(grant);
3469 let grant_tags: Vec<&str> = grant
3470 .props
3471 .get("grants_item_status_tags")
3472 .map(|s| {
3473 s.split(',')
3474 .map(str::trim)
3475 .filter(|t| !t.is_empty())
3476 .collect()
3477 })
3478 .unwrap_or_default();
3479 let grant_id = grant.item_instance_id;
3480 let mut out = Vec::new();
3481 let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
3482 let Some(iid) = stack.item_instance_id else {
3483 return;
3484 };
3485 if Some(iid) == grant_id {
3486 return;
3487 }
3488 if stack.props.get("enchantable").map(String::as_str) == Some("0") {
3489 return;
3490 }
3491 if !grant_target_matches_mode(stack, mode) {
3492 return;
3493 }
3494 if !grant_tags_match(stack, &grant_tags) {
3495 return;
3496 }
3497 let name = stack
3498 .display_name
3499 .clone()
3500 .unwrap_or_else(|| stack.template_id.clone());
3501 let bindings = if stack.status_bindings.is_empty() {
3502 String::new()
3503 } else {
3504 format!(
3505 " · {}",
3506 stack
3507 .status_bindings
3508 .iter()
3509 .map(|b| b.effect_id.as_str())
3510 .collect::<Vec<_>>()
3511 .join(", ")
3512 )
3513 };
3514 out.push(GrantTargetOption {
3515 label: format!("{where_label}: {name}{bindings}"),
3516 target_instance_id: iid,
3517 });
3518 };
3519 fn walk(
3520 stacks: &[flatland_protocol::ItemStack],
3521 where_label: &str,
3522 push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
3523 ) {
3524 for s in stacks {
3525 push(s, where_label);
3526 if !s.contents.is_empty() {
3527 let nested = format!(
3528 "{where_label}/{}",
3529 s.display_name.as_deref().unwrap_or(s.template_id.as_str())
3530 );
3531 walk(&s.contents, &nested, push);
3532 }
3533 }
3534 }
3535 walk(&self.inventory_stacks, "Bag", &mut push);
3536 for (slot, stack) in &self.worn {
3537 push(stack, body_slot_label(*slot));
3538 let nest = format!(
3539 "{}/{}",
3540 body_slot_label(*slot),
3541 stack
3542 .display_name
3543 .as_deref()
3544 .unwrap_or(stack.template_id.as_str())
3545 );
3546 walk(&stack.contents, &nest, &mut push);
3547 }
3548 out
3549 }
3550
3551 pub fn item_base_mass(&self, template_id: &str) -> f32 {
3552 self.inventory_hints
3553 .get(template_id)
3554 .and_then(|h| h.base_mass)
3555 .unwrap_or(0.5)
3556 }
3557
3558 pub fn item_base_volume(&self, template_id: &str) -> f32 {
3559 self.inventory_hints
3560 .get(template_id)
3561 .and_then(|h| h.base_volume)
3562 .unwrap_or(1.0)
3563 }
3564
3565 pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
3566 let unit = stack
3567 .base_mass
3568 .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
3569 unit * stack.quantity as f32
3570 }
3571
3572 fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
3573 let unit = stack.base_volume.unwrap_or(1.0);
3574 unit * stack.quantity as f32
3575 + stack
3576 .contents
3577 .iter()
3578 .map(Self::stack_tree_volume)
3579 .sum::<f32>()
3580 }
3581
3582 fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
3583 contents.iter().map(Self::stack_tree_volume).sum()
3584 }
3585
3586 fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
3587 self.inventory_hints
3588 .get(template_id)
3589 .and_then(|h| h.capacity_volume)
3590 .filter(|c| *c > 0.0)
3591 }
3592
3593 fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
3594 stack
3595 .capacity_volume
3596 .filter(|c| *c > 0.0)
3597 .or_else(|| self.template_capacity_volume(&stack.template_id))
3598 }
3599
3600 pub fn container_volume_label(&self, row: &InventoryRow) -> String {
3602 let Some((used, cap)) = self.container_volume_stats(row) else {
3603 return String::new();
3604 };
3605 let free = (cap - used).max(0.0);
3606 format!(" vol {used:.0}/{cap:.0} ({free:.0} free)")
3607 }
3608
3609 fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
3610 if row.is_chest_shell {
3611 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
3612 return None;
3613 };
3614 let chest = self
3615 .placed_containers
3616 .iter()
3617 .find(|c| c.id == *container_id)?;
3618 let cap = self
3619 .stack_capacity_volume(&row.stack)
3620 .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
3621 let used = if chest.accessible {
3622 Self::contents_used_volume(&chest.contents)
3623 } else {
3624 0.0
3625 };
3626 return Some((used, cap));
3627 }
3628
3629 let cap = self.stack_capacity_volume(&row.stack)?;
3630 let used = Self::contents_used_volume(&row.stack.contents);
3631 Some((used, cap))
3632 }
3633
3634 pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
3635 if row.is_chest_shell {
3636 return true;
3637 }
3638 if row.is_equip_shell {
3639 return self.inventory_item_category(&row.stack.template_id) == Some("container");
3640 }
3641 self.inventory_item_category(&row.stack.template_id) == Some("container")
3642 || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
3643 }
3644
3645 fn container_stack_for(
3646 &self,
3647 location: &flatland_protocol::InventoryLocation,
3648 parent_instance_id: Option<uuid::Uuid>,
3649 ) -> Option<flatland_protocol::ItemStack> {
3650 match location {
3651 flatland_protocol::InventoryLocation::Root => {
3652 let pid = parent_instance_id?;
3653 self.find_stack_by_instance(&self.inventory_stacks, pid)
3654 }
3655 flatland_protocol::InventoryLocation::Worn { slot } => {
3656 let worn = self.worn.get(slot)?;
3657 if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
3658 Some(worn.clone())
3659 } else {
3660 self.find_stack_by_instance(&worn.contents, parent_instance_id?)
3661 }
3662 }
3663 flatland_protocol::InventoryLocation::Placed { container_id } => {
3664 let chest = self
3665 .placed_containers
3666 .iter()
3667 .find(|c| c.id == *container_id)?;
3668 if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
3669 Some(flatland_protocol::ItemStack {
3670 template_id: chest.template_id.clone(),
3671 quantity: 1,
3672 item_instance_id: chest.item_instance_id,
3673 props: Default::default(),
3674 status_bindings: Vec::new(),
3675 contents: chest.contents.clone(),
3676 display_name: Some(chest.display_name.clone()),
3677 category: Some("container".into()),
3678 capacity_volume: self
3679 .inventory_hints
3680 .get(&chest.template_id)
3681 .and_then(|h| h.capacity_volume),
3682 worker_lodging_capacity: chest.worker_lodging_capacity,
3683 ..Default::default()
3684 })
3685 } else {
3686 self.find_stack_by_instance(&chest.contents, parent_instance_id?)
3687 }
3688 }
3689 flatland_protocol::InventoryLocation::Keychain => None,
3690 flatland_protocol::InventoryLocation::WhisperPouch => None,
3691 }
3692 }
3693
3694 fn find_stack_by_instance(
3695 &self,
3696 stacks: &[flatland_protocol::ItemStack],
3697 instance_id: uuid::Uuid,
3698 ) -> Option<flatland_protocol::ItemStack> {
3699 for stack in stacks {
3700 if stack.item_instance_id == Some(instance_id) {
3701 return Some(stack.clone());
3702 }
3703 if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
3704 return Some(found);
3705 }
3706 }
3707 None
3708 }
3709
3710 pub fn max_movable_to(
3712 &self,
3713 template_id: &str,
3714 stack_qty: u32,
3715 from: &flatland_protocol::InventoryLocation,
3716 to: &flatland_protocol::InventoryLocation,
3717 parent_instance_id: Option<uuid::Uuid>,
3718 ) -> u32 {
3719 let unit_vol = self.item_base_volume(template_id);
3720 let mut limit = stack_qty;
3721
3722 if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
3723 let cap = parent
3724 .capacity_volume
3725 .or_else(|| {
3726 self.inventory_hints
3727 .get(&parent.template_id)
3728 .and_then(|h| h.capacity_volume)
3729 })
3730 .unwrap_or(0.0);
3731 if cap > 0.0 && unit_vol > 0.0 {
3732 let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
3733 limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
3734 }
3735 }
3736
3737 let _ = from;
3738 limit.max(0).min(stack_qty)
3739 }
3740
3741 pub fn move_picker_max_at_selection(&self) -> u32 {
3742 let Some(picker) = &self.move_picker else {
3743 return 1;
3744 };
3745 let Some(opt) = picker.options.get(self.move_picker_index) else {
3746 return picker.stack_quantity;
3747 };
3748 match &opt.kind {
3749 MoveOptionKind::Cancel
3750 | MoveOptionKind::Drop
3751 | MoveOptionKind::Use
3752 | MoveOptionKind::GrantApply
3753 | MoveOptionKind::SellPlotToCrown { .. }
3754 | MoveOptionKind::PickupPlaced { .. }
3755 | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
3756 MoveOptionKind::Move {
3757 location,
3758 parent_instance_id,
3759 } => self.max_movable_to(
3760 &picker.template_id,
3761 picker.stack_quantity,
3762 &picker.from,
3763 location,
3764 *parent_instance_id,
3765 ),
3766 }
3767 }
3768
3769 pub fn clamp_move_picker_quantity(&mut self) {
3770 let max = self.move_picker_max_at_selection();
3771 if let Some(picker) = &mut self.move_picker {
3772 if max == 0 {
3773 picker.quantity = 1;
3774 } else {
3775 picker.quantity = picker.quantity.clamp(1, max);
3776 }
3777 }
3778 }
3779
3780 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
3781 let max = self.move_picker_max_at_selection().max(1);
3782 if let Some(picker) = &mut self.move_picker {
3783 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3784 picker.quantity = next as u32;
3785 }
3786 }
3787
3788 pub fn move_picker_set_quantity_max(&mut self) {
3789 let max = self.move_picker_max_at_selection();
3790 if let Some(picker) = &mut self.move_picker {
3791 picker.quantity = if max == 0 {
3792 1
3793 } else {
3794 max.min(picker.stack_quantity)
3795 };
3796 }
3797 }
3798
3799 pub fn move_picker_set_quantity_min(&mut self) {
3800 if let Some(picker) = &mut self.move_picker {
3801 picker.quantity = 1;
3802 }
3803 }
3804
3805 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
3806 if let Some(picker) = &mut self.destroy_picker {
3807 let max = picker.stack_quantity.max(1);
3808 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3809 picker.quantity = next as u32;
3810 }
3811 }
3812
3813 pub fn destroy_picker_set_quantity_max(&mut self) {
3814 if let Some(picker) = &mut self.destroy_picker {
3815 picker.quantity = picker.stack_quantity.max(1);
3816 }
3817 }
3818
3819 pub fn destroy_picker_set_quantity_min(&mut self) {
3820 if let Some(picker) = &mut self.destroy_picker {
3821 picker.quantity = 1;
3822 }
3823 }
3824
3825 pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3826 let have = self.inventory.get(template_id).copied().unwrap_or(0);
3827 (have, have >= need)
3828 }
3829
3830 pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3832 let have = self
3833 .plot_build_offer
3834 .as_ref()
3835 .and_then(|o| {
3836 o.available
3837 .iter()
3838 .find(|s| s.template_id == template_id)
3839 .map(|s| s.quantity)
3840 })
3841 .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
3842 (have, have >= need)
3843 }
3844
3845 pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3846 self.building_materials
3847 .iter()
3848 .filter(|m| m.can_wall)
3849 .collect()
3850 }
3851
3852 pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3853 self.building_materials
3854 .iter()
3855 .filter(|m| m.can_roof)
3856 .collect()
3857 }
3858
3859 pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3860 self.plot_build_wall_options()
3861 .get(self.plot_build_wall_index)
3862 .copied()
3863 }
3864
3865 pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3866 self.plot_build_roof_options()
3867 .get(self.plot_build_roof_index)
3868 .copied()
3869 }
3870
3871 pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
3873 let Some(wall) = self.plot_build_selected_wall() else {
3874 return Vec::new();
3875 };
3876 let Some(roof) = self.plot_build_selected_roof() else {
3877 return Vec::new();
3878 };
3879 let area = self
3880 .plot_build_offer
3881 .as_ref()
3882 .filter(|o| o.pad_ok)
3883 .map(|o| o.pad_width_m * o.pad_depth_m)
3884 .unwrap_or(0.0);
3885 if area <= 0.0 {
3886 return Vec::new();
3887 }
3888 let mut map: std::collections::HashMap<String, (String, u32)> =
3889 std::collections::HashMap::new();
3890 for line in &wall.wall_bom {
3891 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3892 if qty == 0 {
3893 continue;
3894 }
3895 let name = if line.display_name.is_empty() {
3896 line.template_id.clone()
3897 } else {
3898 line.display_name.clone()
3899 };
3900 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3901 entry.1 = entry.1.saturating_add(qty);
3902 }
3903 for line in &roof.roof_bom {
3904 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3905 if qty == 0 {
3906 continue;
3907 }
3908 let name = if line.display_name.is_empty() {
3909 line.template_id.clone()
3910 } else {
3911 line.display_name.clone()
3912 };
3913 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3914 entry.1 = entry.1.saturating_add(qty);
3915 }
3916 let mut out: Vec<_> = map
3917 .into_iter()
3918 .map(|(id, (name, qty))| (id, name, qty))
3919 .collect();
3920 out.sort_by(|a, b| a.0.cmp(&b.0));
3921 out
3922 }
3923
3924 pub fn plot_build_duration_secs(&self) -> Option<f32> {
3925 let wall = self.plot_build_selected_wall()?;
3926 let roof = self.plot_build_selected_roof()?;
3927 let offer = self.plot_build_offer.as_ref()?;
3928 if !offer.pad_ok {
3929 return None;
3930 }
3931 let area = offer.pad_width_m * offer.pad_depth_m;
3932 let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
3933 let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
3934 Some(ticks.max(2.0) / 30.0)
3935 }
3936
3937 pub fn plot_build_can_afford(&self) -> bool {
3938 if self.plot_build_offer.as_ref().is_none_or(|o| !o.pad_ok) {
3939 return false;
3940 }
3941 self.plot_build_bom_lines()
3942 .iter()
3943 .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
3944 }
3945
3946 pub fn currency_display(&self) -> String {
3947 crate::currency::currency_line(&self.inventory)
3948 }
3949
3950 pub fn in_shallow_water(&self) -> bool {
3952 let (px, py) = self.player_position();
3953 self.terrain_at(px, py)
3954 .is_some_and(|k| k == TerrainKindView::ShallowWater)
3955 }
3956
3957 pub fn near_liquid_fill_source(&self) -> bool {
3959 let (px, py) = self.player_position();
3960 const CELL: f32 = 1.0;
3961 let offsets = [
3962 (0.0, 0.0),
3963 (CELL, 0.0),
3964 (-CELL, 0.0),
3965 (0.0, CELL),
3966 (0.0, -CELL),
3967 ];
3968 for (dx, dy) in offsets {
3969 if matches!(
3970 self.terrain_at(px + dx, py + dy),
3971 Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
3972 ) {
3973 return true;
3974 }
3975 }
3976 self.buildings.iter().any(|b| {
3977 if !b.tags.iter().any(|t| t == "well") {
3978 return false;
3979 }
3980 let hw = b.width_m * 0.5;
3981 let hd = b.depth_m * 0.5;
3982 let nx = px.clamp(b.x - hw, b.x + hw);
3983 let ny = py.clamp(b.y - hd, b.y + hd);
3984 let dx = px - nx;
3985 let dy = py - ny;
3986 dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
3987 })
3988 }
3989
3990 pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
3991 self.terrain_zone_at(x, y).map(|z| z.kind)
3992 }
3993
3994 pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
3996 use std::cell::RefCell;
3997
3998 const CHUNK: i32 = 8;
3999 thread_local! {
4000 static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
4001 RefCell::new(None);
4002 }
4003
4004 let zones = &self.terrain_zones;
4005 if zones.is_empty() {
4006 return None;
4007 }
4008 if zones.len() <= 48 {
4009 return zones
4010 .iter()
4011 .enumerate()
4012 .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
4013 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
4014 .map(|(_, z)| z);
4015 }
4016
4017 let ptr = zones.as_ptr();
4018 let len = zones.len();
4019 INDEX.with(|cell| {
4020 let mut slot = cell.borrow_mut();
4021 let stale = match slot.as_ref() {
4022 Some((p, l, _)) => *p != ptr || *l != len,
4023 None => true,
4024 };
4025 if stale {
4026 let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
4027 std::collections::HashMap::new();
4028 for (zi, z) in zones.iter().enumerate() {
4029 let x0 = z.x0.min(z.x1).floor() as i32;
4030 let y0 = z.y0.min(z.y1).floor() as i32;
4031 let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
4032 let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
4033 let cx0 = x0.div_euclid(CHUNK);
4034 let cy0 = y0.div_euclid(CHUNK);
4035 let cx1 = x1.div_euclid(CHUNK);
4036 let cy1 = y1.div_euclid(CHUNK);
4037 for cy in cy0..=cy1 {
4038 for cx in cx0..=cx1 {
4039 chunks.entry((cx, cy)).or_default().push(zi);
4040 }
4041 }
4042 }
4043 *slot = Some((ptr, len, chunks));
4044 }
4045 let chunks = &slot.as_ref().expect("index").2;
4046 let cx = (x.floor() as i32).div_euclid(CHUNK);
4047 let cy = (y.floor() as i32).div_euclid(CHUNK);
4048 let mut best: Option<(usize, &TerrainZoneView)> = None;
4049 if let Some(list) = chunks.get(&(cx, cy)) {
4050 for &zi in list {
4051 let Some(z) = zones.get(zi) else { continue };
4052 if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
4053 continue;
4054 }
4055 best = match best {
4056 None => Some((zi, z)),
4057 Some((bi, bz)) => {
4058 if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
4059 Some((zi, z))
4060 } else {
4061 Some((bi, bz))
4062 }
4063 }
4064 };
4065 }
4066 }
4067 best.map(|(_, z)| z)
4068 })
4069 }
4070
4071 pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
4073 self.terrain_zone_at(x, y)
4074 .map(|z| z.elevation)
4075 .unwrap_or(0.0)
4076 }
4077
4078 pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
4080 const TOL: f32 = 0.35;
4081 let mut levels = vec![self.elevation_at(x, y)];
4082 for p in &self.z_platforms {
4083 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4084 levels.push(p.z);
4085 }
4086 }
4087 levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
4088 levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
4089 levels
4090 }
4091
4092 pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
4093 const TOL: f32 = 0.35;
4094 self.walkable_levels_at(x, y)
4095 .iter()
4096 .any(|&l| (l - z).abs() <= TOL)
4097 }
4098
4099 pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
4100 let mut top = self.elevation_at(x, y);
4101 for p in &self.z_platforms {
4102 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4103 top = top.max(p.z);
4104 }
4105 }
4106 top
4107 }
4108
4109 pub fn effective_inside_building(&self) -> Option<String> {
4111 self.player_entity().and_then(|p| p.inside_building.clone())
4112 }
4113
4114 pub fn placed_container_in_current_space(
4118 &self,
4119 c: &flatland_protocol::PlacedContainerView,
4120 ) -> bool {
4121 match (
4122 self.effective_inside_building().as_deref(),
4123 c.building_id.as_deref(),
4124 ) {
4125 (None, None) => true,
4126 (Some(a), Some(b)) => a == b,
4127 _ => false,
4128 }
4129 }
4130
4131 fn merge_stack_catalog_hints(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4132 fn walk(
4133 stacks: &[flatland_protocol::ItemStack],
4134 hints: &mut std::collections::HashMap<String, InventoryHint>,
4135 ) {
4136 for stack in stacks {
4137 if stack.display_name.is_some()
4138 || stack.category.is_some()
4139 || stack.base_mass.is_some()
4140 || stack.base_volume.is_some()
4141 || stack.base_value_copper.is_some()
4142 {
4143 hints.insert(
4144 stack.template_id.clone(),
4145 InventoryHint {
4146 display_name: stack
4147 .display_name
4148 .clone()
4149 .unwrap_or_else(|| stack.template_id.clone()),
4150 category: stack.category.clone().unwrap_or_default(),
4151 base_mass: stack.base_mass,
4152 base_volume: stack.base_volume,
4153 capacity_volume: stack.capacity_volume,
4154 stackable: stack.stackable.unwrap_or(true),
4155 listable: stack.listable.unwrap_or_else(|| {
4156 category_default_listable(stack.category.as_deref().unwrap_or(""))
4157 }),
4158 base_value_copper: stack.base_value_copper,
4159 },
4160 );
4161 }
4162 walk(&stack.contents, hints);
4163 }
4164 }
4165 walk(stacks, &mut self.inventory_hints);
4166 }
4167
4168 pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4169 self.inventory_stacks = stacks.to_vec();
4170 self.inventory.clear();
4171 self.inventory_hints.clear();
4172 fn walk(
4173 stacks: &[flatland_protocol::ItemStack],
4174 inventory: &mut std::collections::HashMap<String, u32>,
4175 hints: &mut std::collections::HashMap<String, InventoryHint>,
4176 ) {
4177 for stack in stacks {
4178 *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
4179 if stack.display_name.is_some()
4180 || stack.category.is_some()
4181 || stack.base_mass.is_some()
4182 || stack.base_volume.is_some()
4183 || stack.base_value_copper.is_some()
4184 {
4185 hints.insert(
4186 stack.template_id.clone(),
4187 InventoryHint {
4188 display_name: stack
4189 .display_name
4190 .clone()
4191 .unwrap_or_else(|| stack.template_id.clone()),
4192 category: stack.category.clone().unwrap_or_default(),
4193 base_mass: stack.base_mass,
4194 base_volume: stack.base_volume,
4195 capacity_volume: stack.capacity_volume,
4196 stackable: stack.stackable.unwrap_or(true),
4197 listable: stack.listable.unwrap_or_else(|| {
4198 category_default_listable(stack.category.as_deref().unwrap_or(""))
4199 }),
4200 base_value_copper: stack.base_value_copper,
4201 },
4202 );
4203 }
4204 walk(&stack.contents, inventory, hints);
4205 }
4206 }
4207 walk(stacks, &mut self.inventory, &mut self.inventory_hints);
4208 for item in self.worn.values() {
4210 walk(
4211 std::slice::from_ref(item),
4212 &mut self.inventory,
4213 &mut self.inventory_hints,
4214 );
4215 }
4216 }
4217
4218 fn sync_item_catalog(&mut self, entries: &[ItemCatalogEntryView]) {
4219 if entries.is_empty() {
4220 return;
4221 }
4222 self.item_catalog.clear();
4223 self.item_catalog.reserve(entries.len());
4224 for entry in entries {
4225 if entry.template_id.is_empty() {
4226 continue;
4227 }
4228 self.item_catalog
4229 .insert(entry.template_id.clone(), entry.clone());
4230 }
4231 }
4232
4233 fn remove_carried_instance(&mut self, instance_id: uuid::Uuid, quantity: Option<u32>) {
4237 fn take_from(
4238 stacks: &mut Vec<flatland_protocol::ItemStack>,
4239 instance_id: uuid::Uuid,
4240 qty: Option<u32>,
4241 ) -> bool {
4242 if let Some(i) = stacks
4243 .iter()
4244 .position(|s| s.item_instance_id == Some(instance_id))
4245 {
4246 let have = stacks[i].quantity;
4247 let take = qty.unwrap_or(have).min(have);
4248 if take >= have {
4249 stacks.remove(i);
4250 } else {
4251 stacks[i].quantity = have - take;
4252 }
4253 return true;
4254 }
4255 stacks
4256 .iter_mut()
4257 .any(|stack| take_from(&mut stack.contents, instance_id, qty))
4258 }
4259
4260 if take_from(&mut self.inventory_stacks, instance_id, quantity) {
4261 let stacks = self.inventory_stacks.clone();
4262 self.sync_inventory_from_stacks(&stacks);
4263 self.refresh_inventory_ui();
4264 return;
4265 }
4266 let slots: Vec<_> = self.worn.keys().copied().collect();
4267 for slot in slots {
4268 let Some(item) = self.worn.get_mut(&slot) else {
4269 continue;
4270 };
4271 if take_from(&mut item.contents, instance_id, quantity) {
4272 let stacks = self.inventory_stacks.clone();
4273 self.sync_inventory_from_stacks(&stacks);
4274 self.refresh_inventory_ui();
4275 return;
4276 }
4277 }
4278 }
4279
4280 pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
4283 if notice.message.starts_with("Gave ") {
4287 if notice.coins_delta != 0 {
4288 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4289 let stacks = self.inventory_stacks.clone();
4290 self.sync_inventory_from_stacks(&stacks);
4291 }
4292 self.record_shop_trade_notice(notice);
4293 return;
4294 }
4295 let subtract_items =
4296 notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
4297 for stack in ¬ice.inventory_delta {
4298 if stack.quantity == 0 {
4299 continue;
4300 }
4301 if subtract_items {
4302 crate::currency::drain_template_stacks(
4303 &mut self.inventory_stacks,
4304 &stack.template_id,
4305 stack.quantity,
4306 );
4307 continue;
4308 }
4309 let stackable = self
4310 .inventory_hints
4311 .get(&stack.template_id)
4312 .map(|h| h.stackable)
4313 .or(stack.stackable)
4314 .unwrap_or(true);
4315 if stackable {
4316 if let Some(existing) = self
4317 .inventory_stacks
4318 .iter_mut()
4319 .find(|s| s.template_id == stack.template_id)
4320 {
4321 existing.quantity = existing.quantity.saturating_add(stack.quantity);
4322 if stack.display_name.is_some() {
4323 existing.display_name = stack.display_name.clone();
4324 }
4325 if stack.category.is_some() {
4326 existing.category = stack.category.clone();
4327 }
4328 continue;
4329 }
4330 }
4331 self.inventory_stacks.push(stack.clone());
4332 }
4333 if notice.coins_delta != 0 {
4334 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4335 }
4336 if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
4337 let stacks = self.inventory_stacks.clone();
4338 self.sync_inventory_from_stacks(&stacks);
4339 }
4340 self.record_shop_trade_notice(notice);
4341 }
4342
4343 pub fn worn_rows(&self) -> Vec<InventoryRow> {
4348 let mut rows = Vec::new();
4349 for (slot, item) in &self.worn {
4350 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4351 rows.push(InventoryRow {
4352 depth: 0,
4353 stack: item.clone(),
4354 from: from.clone(),
4355 from_parent_instance_id: None,
4356 is_equip_shell: true,
4357 is_chest_shell: false,
4358 section: InventorySection::Worn,
4359 });
4360 for child in &item.contents {
4361 push_inventory_rows(
4362 &mut rows,
4363 1,
4364 child,
4365 &from,
4366 item.item_instance_id,
4367 InventorySection::Worn,
4368 );
4369 }
4370 }
4371 rows
4372 }
4373
4374 pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
4376 let equipped = self.hand_equipped_instance_ids();
4377 self.inventory_stacks
4378 .iter()
4379 .filter(|s| s.item_instance_id.is_some_and(|id| !equipped.contains(&id)))
4380 .collect()
4381 }
4382
4383 pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
4385 let equipped = self.hand_equipped_instance_ids();
4386 self.inventory_stacks
4387 .iter()
4388 .filter_map(|stack| {
4389 let item_instance_id = stack.item_instance_id?;
4390 if equipped.contains(&item_instance_id) {
4391 return None;
4392 }
4393 let label = stack
4394 .display_name
4395 .clone()
4396 .unwrap_or_else(|| stack.template_id.clone());
4397 let label = if stack.quantity > 1 {
4398 format!("{label} ×{}", stack.quantity)
4399 } else {
4400 label
4401 };
4402 Some(WorkerGiveOption {
4403 item_instance_id,
4404 label,
4405 quantity: stack.quantity,
4406 template_id: stack.template_id.clone(),
4407 })
4408 })
4409 .collect()
4410 }
4411
4412 pub fn teachable_blueprint_options(
4414 &self,
4415 worker: &flatland_protocol::HiredWorkerView,
4416 ) -> Vec<WorkerTeachOption> {
4417 let copper = crate::currency::copper_from_counts(&self.inventory);
4418 let mut options: Vec<WorkerTeachOption> = self
4419 .blueprints
4420 .iter()
4421 .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
4422 .map(|bp| {
4423 let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
4424 let cost = bp.worker_train_copper;
4425 WorkerTeachOption {
4426 blueprint_id: bp.id.clone(),
4427 label: if bp.label.is_empty() {
4428 bp.id.clone()
4429 } else {
4430 bp.label.clone()
4431 },
4432 cost_copper: cost,
4433 min_level,
4434 worker_level: worker.level,
4435 can_afford: copper >= cost,
4436 level_ok: worker.level >= min_level,
4437 }
4438 })
4439 .collect();
4440 options.sort_by(|a, b| a.label.cmp(&b.label));
4441 options
4442 }
4443
4444 pub fn person_rows(&self) -> Vec<InventoryRow> {
4447 self.person_rows_filtered("")
4448 }
4449
4450 pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4451 let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
4452 roots.sort_by(|a, b| {
4453 let ca = a
4454 .category
4455 .as_deref()
4456 .or_else(|| self.inventory_item_category(&a.template_id))
4457 .unwrap_or("");
4458 let cb = b
4459 .category
4460 .as_deref()
4461 .or_else(|| self.inventory_item_category(&b.template_id))
4462 .unwrap_or("");
4463 let ga = inventory_category_group(ca).1;
4464 let gb = inventory_category_group(cb).1;
4465 ga.cmp(&gb).then_with(|| {
4466 let na = a.display_name.as_deref().unwrap_or(a.template_id.as_str());
4467 let nb = b.display_name.as_deref().unwrap_or(b.template_id.as_str());
4468 na.cmp(nb)
4469 })
4470 });
4471 let mut rows = Vec::new();
4472 for stack in roots {
4473 push_inventory_rows_filtered(
4474 &mut rows,
4475 0,
4476 stack,
4477 &flatland_protocol::InventoryLocation::Root,
4478 None,
4479 InventorySection::Person,
4480 filter,
4481 );
4482 }
4483 rows
4484 }
4485
4486 pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4487 if filter.is_empty() {
4488 return self.worn_rows();
4489 }
4490 let mut rows = Vec::new();
4491 for (slot, item) in &self.worn {
4492 if !stack_matches_filter(item, filter) {
4493 continue;
4494 }
4495 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4496 let self_hit = {
4497 let f = filter.to_ascii_lowercase();
4498 let name = item
4499 .display_name
4500 .as_deref()
4501 .unwrap_or("")
4502 .to_ascii_lowercase();
4503 let tid = item.template_id.to_ascii_lowercase();
4504 name.contains(&f) || tid.contains(&f)
4505 };
4506 rows.push(InventoryRow {
4507 depth: 0,
4508 stack: item.clone(),
4509 from: from.clone(),
4510 from_parent_instance_id: None,
4511 is_equip_shell: true,
4512 is_chest_shell: false,
4513 section: InventorySection::Worn,
4514 });
4515 for child in &item.contents {
4516 if self_hit || stack_matches_filter(child, filter) {
4517 push_inventory_rows_filtered(
4518 &mut rows,
4519 1,
4520 child,
4521 &from,
4522 item.item_instance_id,
4523 InventorySection::Worn,
4524 if self_hit { "" } else { filter },
4525 );
4526 }
4527 }
4528 }
4529 rows
4530 }
4531
4532 pub fn carried_worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4536 let mut rows = Vec::new();
4537 for (slot, item) in &self.worn {
4538 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4539 for child in &item.contents {
4540 push_inventory_rows_filtered(
4541 &mut rows,
4542 0,
4543 child,
4544 &from,
4545 item.item_instance_id,
4546 InventorySection::Person,
4547 filter,
4548 );
4549 }
4550 }
4551 rows
4552 }
4553
4554 pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
4556 let mut rows = self.worn_rows();
4557 rows.extend(self.person_rows());
4558 rows.into_iter().map(|r| (r.depth, r.stack)).collect()
4559 }
4560
4561 pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
4565 let (px, py) = self.player_position();
4566 let mut list: Vec<NearbyContainer> = self
4567 .placed_containers
4568 .iter()
4569 .filter(|c| self.placed_container_in_current_space(c))
4570 .filter_map(|c| {
4571 let distance_m = (c.x - px).hypot(c.y - py);
4572 if distance_m > CONTAINER_RANGE_M {
4573 return None;
4574 }
4575 let mut rows = Vec::new();
4576 let from = flatland_protocol::InventoryLocation::Placed {
4577 container_id: c.id.clone(),
4578 };
4579 rows.push(InventoryRow {
4580 depth: 0,
4581 stack: flatland_protocol::ItemStack {
4582 template_id: c.template_id.clone(),
4583 quantity: 1,
4584 item_instance_id: c.item_instance_id,
4585 props: Default::default(),
4586 status_bindings: Vec::new(),
4587 contents: Vec::new(),
4588 display_name: Some(c.display_name.clone()),
4589 category: Some("container".into()),
4590 capacity_volume: c.capacity_volume,
4591 worker_lodging_capacity: c.worker_lodging_capacity,
4592 ..Default::default()
4593 },
4594 from: from.clone(),
4595 from_parent_instance_id: None,
4596 is_equip_shell: false,
4597 is_chest_shell: true,
4598 section: InventorySection::Nearby,
4599 });
4600 if c.accessible {
4601 for child in &c.contents {
4602 push_inventory_rows(
4603 &mut rows,
4604 1,
4605 child,
4606 &from,
4607 c.item_instance_id,
4608 InventorySection::Nearby,
4609 );
4610 }
4611 }
4612 Some(NearbyContainer {
4613 view: c.clone(),
4614 distance_m,
4615 rows,
4616 })
4617 })
4618 .collect();
4619 list.sort_by(|a, b| {
4620 a.distance_m
4621 .partial_cmp(&b.distance_m)
4622 .unwrap_or(std::cmp::Ordering::Equal)
4623 });
4624 list
4625 }
4626
4627 pub fn nearest_placed_container(
4629 &self,
4630 max_dist: f32,
4631 ) -> Option<flatland_protocol::PlacedContainerView> {
4632 let (px, py) = self.player_position();
4633 self.placed_containers
4634 .iter()
4635 .filter(|c| self.placed_container_in_current_space(c))
4636 .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
4637 .min_by(|a, b| {
4638 let da = (a.x - px).hypot(a.y - py);
4639 let db = (b.x - px).hypot(b.y - py);
4640 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
4641 })
4642 .cloned()
4643 }
4644
4645 pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
4648 let filter = self.inventory_filter.as_str();
4649 match self.inventory_tab {
4650 InventoryTab::OnPerson => {
4651 let mut rows = self.carried_worn_rows_filtered(filter);
4652 rows.extend(self.person_rows_filtered(filter));
4653 rows
4654 }
4655 InventoryTab::Nearby => {
4656 let mut rows = Vec::new();
4657 for nc in self.nearby_containers() {
4658 if filter.is_empty() {
4659 rows.extend(nc.rows);
4660 continue;
4661 }
4662 let shell = nc.rows.first().cloned();
4663 let contents: Vec<_> = nc
4664 .rows
4665 .iter()
4666 .skip(1)
4667 .filter(|r| stack_matches_filter(&r.stack, filter))
4668 .cloned()
4669 .collect();
4670 let shell_hit = shell
4671 .as_ref()
4672 .map(|s| stack_matches_filter(&s.stack, filter))
4673 .unwrap_or(false);
4674 if shell_hit || !contents.is_empty() {
4675 if let Some(s) = shell {
4676 rows.push(s);
4677 }
4678 if shell_hit {
4679 rows.extend(nc.rows.into_iter().skip(1));
4680 } else {
4681 rows.extend(contents);
4682 }
4683 }
4684 }
4685 rows
4686 }
4687 }
4688 }
4689
4690 pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
4691 self.inventory_selectable_rows()
4692 .into_iter()
4693 .nth(self.inventory_menu_index)
4694 }
4695
4696 fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
4697 let cat = self
4698 .inventory_item_category(&row.stack.template_id)
4699 .unwrap_or("");
4700 if cat == "key" {
4701 self.key_inventory_label(&row.stack)
4702 } else {
4703 row.stack
4704 .display_name
4705 .clone()
4706 .unwrap_or_else(|| row.stack.template_id.clone())
4707 }
4708 }
4709
4710 fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
4712 let bindings =
4713 format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4714 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4715 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4716 let mode = Self::grant_mode(&row.stack);
4717 format!(" [grant {effect} · {mode} — e apply]")
4718 } else {
4719 String::new()
4720 };
4721 let qty = if row.stack.quantity > 1 {
4722 format!(" ×{}", row.stack.quantity)
4723 } else {
4724 String::new()
4725 };
4726 let worn_slot = if row.is_equip_shell {
4727 match row.from {
4728 flatland_protocol::InventoryLocation::Worn { slot } => {
4729 format!(" ({})", body_slot_label(slot))
4730 }
4731 _ => String::new(),
4732 }
4733 } else {
4734 String::new()
4735 };
4736 format!("{grant_hint}{bindings}{qty}{worn_slot}")
4737 }
4738
4739 fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
4740 (
4741 row.stack.template_id.clone(),
4742 self.inventory_row_base_label(row),
4743 self.inventory_row_visible_mod_signature(row),
4744 )
4745 }
4746
4747 fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
4749 let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
4750 for row in self.inventory_selectable_rows() {
4751 if row.stack.item_instance_id.is_none() {
4752 continue;
4753 }
4754 let key = self.inventory_row_instance_identity_key(&row);
4755 *counts.entry(key).or_default() += 1;
4756 }
4757 counts
4758 .into_iter()
4759 .filter(|(_, n)| *n > 1)
4760 .map(|(k, _)| k)
4761 .collect()
4762 }
4763
4764 fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
4765 let hex: String = id
4766 .as_simple()
4767 .to_string()
4768 .chars()
4769 .filter(|c| c.is_ascii_hexdigit())
4770 .collect();
4771 let short = if hex.len() >= 4 {
4772 &hex[hex.len() - 4..]
4773 } else {
4774 hex.as_str()
4775 };
4776 format!("Instance {id} (#{short})")
4777 }
4778
4779 pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
4781 let cat = self
4782 .inventory_item_category(&row.stack.template_id)
4783 .unwrap_or("");
4784 let label = self.inventory_row_base_label(row);
4785 let hint: String = if row.is_equip_shell {
4786 " [worn — Enter to unequip]".into()
4787 } else if row.is_chest_shell {
4788 let (locked, lodging_note) = match &row.from {
4789 flatland_protocol::InventoryLocation::Placed { container_id } => {
4790 let locked = self
4791 .placed_containers
4792 .iter()
4793 .find(|c| c.id == *container_id)
4794 .map(|c| c.locked)
4795 .unwrap_or(false);
4796 let lodging_note = self
4797 .lodging_occupancy_label(container_id)
4798 .map(|who| format!(" [lodging: {who}]"))
4799 .unwrap_or_default();
4800 (locked, lodging_note)
4801 }
4802 _ => (false, String::new()),
4803 };
4804 if locked {
4805 format!(" [locked — Enter pick up · l unlock]{lodging_note}")
4806 } else {
4807 format!(" [Enter pick up · l lock]{lodging_note}")
4808 }
4809 } else if cat == "key" {
4810 self.key_inventory_hint(&row.stack)
4811 } else {
4812 match cat {
4813 "weapon" => " [weapon]".into(),
4814 "container" => " [bag/chest/belt]".into(),
4815 "lodging" => " [worker lodging]".into(),
4816 "armor" => " [armor]".into(),
4817 _ => String::new(),
4818 }
4819 };
4820 let qty = if row.stack.quantity > 1 {
4821 format!(" ×{}", row.stack.quantity)
4822 } else {
4823 String::new()
4824 };
4825 let bindings =
4826 format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4827 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4828 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4829 let mode = Self::grant_mode(&row.stack);
4830 format!(" [grant {effect} · {mode} — e apply]")
4831 } else {
4832 String::new()
4833 };
4834 let mass = self.stack_mass(&row.stack);
4835 let mass_kg = (mass >= 0.05).then_some(mass);
4836 let mass_str = mass_kg.map(|m| format!(" {m:.1} kg")).unwrap_or_default();
4837 let volume = self.container_volume_stats(row);
4838 let vol_str = self.container_volume_label(row);
4839
4840 let mut title = label.clone();
4841 title.push_str(&qty);
4842 if row.is_equip_shell {
4843 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
4844 title.push_str(&format!(" ({})", body_slot_label(slot)));
4845 }
4846 }
4847
4848 InventoryRowView {
4849 depth: row.depth,
4850 text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
4851 title: format!("{title}{grant_hint}{bindings}"),
4852 mass_kg,
4853 volume,
4854 instance_tooltip: None,
4855 }
4856 }
4857
4858 fn push_browser_item(
4859 &self,
4860 lines: &mut Vec<InventoryBrowserLine>,
4861 row: &InventoryRow,
4862 global_idx: &mut usize,
4863 target: usize,
4864 highlight: bool,
4865 ambiguous_instance_keys: &HashSet<(String, String, String)>,
4866 ) {
4867 let mut view = self.format_inventory_row(row);
4868 if let Some(id) = row.stack.item_instance_id {
4869 let key = self.inventory_row_instance_identity_key(row);
4870 if ambiguous_instance_keys.contains(&key) {
4871 view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
4872 }
4873 }
4874 lines.push(InventoryBrowserLine::Item {
4875 selectable_index: *global_idx,
4876 selected: highlight && *global_idx == target,
4877 depth: view.depth,
4878 text: view.text,
4879 title: view.title,
4880 mass_kg: view.mass_kg,
4881 volume: view.volume,
4882 instance_tooltip: view.instance_tooltip,
4883 });
4884 *global_idx += 1;
4885 }
4886
4887 pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
4890 let mut lines = Vec::new();
4891 let target = self.inventory_menu_index;
4892 let highlight = !self.show_move_picker && !self.show_grant_picker;
4893 let filter = self.inventory_filter.as_str();
4894 let mut global_idx = 0usize;
4895 let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
4896
4897 match self.inventory_tab {
4898 InventoryTab::OnPerson => {
4899 lines.push(InventoryBrowserLine::Section("— In carried bags —".into()));
4900 let carried = self.carried_worn_rows_filtered(filter);
4901 if carried.is_empty() {
4902 lines.push(InventoryBrowserLine::Hint(
4903 " (no items in carried bags)".into(),
4904 ));
4905 } else {
4906 for row in &carried {
4907 self.push_browser_item(
4908 &mut lines,
4909 row,
4910 &mut global_idx,
4911 target,
4912 highlight,
4913 &ambiguous_instance_keys,
4914 );
4915 }
4916 }
4917
4918 lines.push(InventoryBrowserLine::Blank);
4919 lines.push(InventoryBrowserLine::Section(
4920 "— On you (loose, not worn) —".into(),
4921 ));
4922 let person = self.person_rows_filtered(filter);
4923 if person.is_empty() {
4924 lines.push(InventoryBrowserLine::Hint(" (empty)".into()));
4925 } else {
4926 let mut last_group: Option<&'static str> = None;
4927 for row in &person {
4928 if row.depth == 0 {
4929 let cat = row
4930 .stack
4931 .category
4932 .as_deref()
4933 .or_else(|| self.inventory_item_category(&row.stack.template_id))
4934 .unwrap_or("");
4935 let (group, _) = inventory_category_group(cat);
4936 if last_group != Some(group) {
4937 lines.push(InventoryBrowserLine::SlotLabel(format!(" {group}")));
4938 last_group = Some(group);
4939 }
4940 }
4941 self.push_browser_item(
4942 &mut lines,
4943 row,
4944 &mut global_idx,
4945 target,
4946 highlight,
4947 &ambiguous_instance_keys,
4948 );
4949 }
4950 }
4951 }
4952 InventoryTab::Nearby => {
4953 let nearby = self.nearby_containers();
4954 if nearby.is_empty() {
4955 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
4956 lines.push(InventoryBrowserLine::Hint(
4957 " (none within reach — walk up to a chest)".into(),
4958 ));
4959 lines.push(InventoryBrowserLine::Hint(
4960 " Select an on-person item, then m / Enter → move into chest.".into(),
4961 ));
4962 } else {
4963 let mut any_visible = false;
4964 for nc in &nearby {
4965 let shell = nc.rows.first();
4966 let contents: Vec<&InventoryRow> = if filter.is_empty() {
4967 nc.rows.iter().skip(1).collect()
4968 } else {
4969 let shell_hit = shell
4970 .map(|s| {
4971 let f = filter.to_ascii_lowercase();
4972 let name = s
4973 .stack
4974 .display_name
4975 .as_deref()
4976 .unwrap_or("")
4977 .to_ascii_lowercase();
4978 let tid = s.stack.template_id.to_ascii_lowercase();
4979 name.contains(&f) || tid.contains(&f)
4980 })
4981 .unwrap_or(false);
4982 if shell_hit {
4983 nc.rows.iter().skip(1).collect()
4984 } else {
4985 nc.rows
4986 .iter()
4987 .skip(1)
4988 .filter(|r| stack_matches_filter(&r.stack, filter))
4989 .collect()
4990 }
4991 };
4992 let shell_visible = filter.is_empty()
4993 || shell
4994 .map(|s| stack_matches_filter(&s.stack, filter))
4995 .unwrap_or(false)
4996 || !contents.is_empty();
4997 if !shell_visible && shell.is_some() {
4998 continue;
4999 }
5000 any_visible = true;
5001 lines.push(InventoryBrowserLine::Blank);
5002 let lock_note = if nc.view.locked && nc.view.accessible {
5003 " unlocked with your key"
5004 } else if nc.view.locked {
5005 " locked"
5006 } else {
5007 ""
5008 };
5009 lines.push(InventoryBrowserLine::Section(format!(
5010 "— {} ({:.0}m away){lock_note} —",
5011 nc.view.display_name, nc.distance_m
5012 )));
5013 if !nc.view.accessible {
5014 lines.push(InventoryBrowserLine::Hint(
5015 " locked — need the matching key (l to try)".into(),
5016 ));
5017 } else if nc.rows.is_empty() {
5018 lines.push(InventoryBrowserLine::Hint(
5019 " (empty — switch to On person, select an item, m to move in)"
5020 .into(),
5021 ));
5022 } else if let Some(shell_row) = shell {
5023 self.push_browser_item(
5024 &mut lines,
5025 shell_row,
5026 &mut global_idx,
5027 target,
5028 highlight,
5029 &ambiguous_instance_keys,
5030 );
5031 for row in contents {
5032 self.push_browser_item(
5033 &mut lines,
5034 row,
5035 &mut global_idx,
5036 target,
5037 highlight,
5038 &ambiguous_instance_keys,
5039 );
5040 }
5041 }
5042 }
5043 if !any_visible {
5044 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
5045 lines.push(InventoryBrowserLine::Hint(
5046 " (no matching items — clear filter with Esc)".into(),
5047 ));
5048 }
5049 }
5050 }
5051 }
5052 lines
5053 }
5054
5055 pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
5057 let mut opts = Vec::new();
5058 opts.push(MoveOption {
5059 label: "Relocate…".into(),
5060 kind: MoveOptionKind::RelocatePlaced {
5061 container_id: container_id.to_string(),
5062 },
5063 });
5064 opts.push(MoveOption {
5065 label: "On your person (loose)".into(),
5066 kind: MoveOptionKind::PickupPlaced {
5067 container_id: container_id.to_string(),
5068 nest_location: flatland_protocol::InventoryLocation::Root,
5069 nest_parent_instance_id: None,
5070 },
5071 });
5072 for (slot, item) in &self.worn {
5073 if item.category.as_deref() != Some("container") {
5074 continue;
5075 }
5076 if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
5077 continue;
5078 }
5079 let Some(parent_id) = item.item_instance_id else {
5080 continue;
5081 };
5082 let shell_name = item
5083 .display_name
5084 .clone()
5085 .unwrap_or_else(|| item.template_id.clone());
5086 opts.push(MoveOption {
5087 label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
5088 kind: MoveOptionKind::PickupPlaced {
5089 container_id: container_id.to_string(),
5090 nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
5091 nest_parent_instance_id: Some(parent_id),
5092 },
5093 });
5094 Self::append_chest_pickup_nested(
5096 &mut opts,
5097 container_id,
5098 flatland_protocol::InventoryLocation::Worn { slot: *slot },
5099 item,
5100 &format!("in {shell_name}"),
5101 );
5102 }
5103 opts.push(MoveOption {
5104 label: "Cancel".into(),
5105 kind: MoveOptionKind::Cancel,
5106 });
5107 opts
5108 }
5109
5110 fn append_chest_pickup_nested(
5111 opts: &mut Vec<MoveOption>,
5112 container_id: &str,
5113 location: flatland_protocol::InventoryLocation,
5114 parent: &flatland_protocol::ItemStack,
5115 context: &str,
5116 ) {
5117 for child in &parent.contents {
5118 if child.category.as_deref() != Some("container") {
5119 continue;
5120 }
5121 if !Self::is_volume_container_stack(child) {
5122 continue;
5123 }
5124 if child.world_placeable == Some(true) {
5126 continue;
5127 }
5128 let Some(child_id) = child.item_instance_id else {
5129 continue;
5130 };
5131 let name = child
5132 .display_name
5133 .clone()
5134 .unwrap_or_else(|| child.template_id.clone());
5135 opts.push(MoveOption {
5136 label: format!("{name} ({context})"),
5137 kind: MoveOptionKind::PickupPlaced {
5138 container_id: container_id.to_string(),
5139 nest_location: location.clone(),
5140 nest_parent_instance_id: Some(child_id),
5141 },
5142 });
5143 Self::append_chest_pickup_nested(
5144 opts,
5145 container_id,
5146 location.clone(),
5147 child,
5148 &format!("in {name}"),
5149 );
5150 }
5151 }
5152
5153 pub fn move_destinations_for(
5155 &self,
5156 from: &flatland_protocol::InventoryLocation,
5157 from_parent_instance_id: Option<uuid::Uuid>,
5158 moving_instance_id: Option<uuid::Uuid>,
5159 moving_template_id: &str,
5160 ) -> Vec<MoveOption> {
5161 let mut opts = Vec::new();
5162 if *from != flatland_protocol::InventoryLocation::Root {
5163 opts.push(MoveOption {
5164 label: "On your person (loose)".into(),
5165 kind: MoveOptionKind::Move {
5166 location: flatland_protocol::InventoryLocation::Root,
5167 parent_instance_id: None,
5168 },
5169 });
5170 }
5171 for (slot, item) in &self.worn {
5172 if item.category.as_deref() != Some("container") {
5173 continue;
5174 }
5175 let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
5176 let shell_name = item
5177 .display_name
5178 .clone()
5179 .unwrap_or_else(|| item.template_id.clone());
5180
5181 if *slot != BodySlot::Waist
5183 && item.item_instance_id != moving_instance_id
5184 && Self::is_volume_container_stack(item)
5185 {
5186 Self::push_move_destination(
5187 &mut opts,
5188 format!("{shell_name} (worn {})", body_slot_label(*slot)),
5189 location.clone(),
5190 item.item_instance_id,
5191 from,
5192 from_parent_instance_id,
5193 );
5194 }
5195
5196 if *slot == BodySlot::Waist
5198 && Self::attaches_to_belt_loop(moving_template_id)
5199 && item.item_instance_id != moving_instance_id
5200 {
5201 Self::push_move_destination(
5202 &mut opts,
5203 format!("{shell_name} (belt loop)"),
5204 location.clone(),
5205 item.item_instance_id,
5206 from,
5207 from_parent_instance_id,
5208 );
5209 }
5210
5211 let context = if *slot == BodySlot::Waist {
5212 format!("on {shell_name}")
5213 } else {
5214 format!("in {shell_name}")
5215 };
5216 Self::append_nested_container_destinations(
5217 &mut opts,
5218 location,
5219 item,
5220 &context,
5221 from,
5222 from_parent_instance_id,
5223 moving_instance_id,
5224 );
5225 }
5226 for nc in self.nearby_containers() {
5227 if !nc.view.accessible {
5228 continue;
5229 }
5230 let location = flatland_protocol::InventoryLocation::Placed {
5231 container_id: nc.view.id.clone(),
5232 };
5233 Self::push_move_destination(
5234 &mut opts,
5235 format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
5236 location,
5237 nc.view.item_instance_id,
5238 from,
5239 from_parent_instance_id,
5240 );
5241 }
5242 let allow_drop = moving_instance_id
5243 .map(|id| !self.hand_equipped_instance_ids().contains(&id))
5244 .unwrap_or(true)
5245 && moving_instance_id
5246 .and_then(|id| self.stack_for_instance(id))
5247 .map(|stack| {
5248 !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
5249 })
5250 .unwrap_or(
5251 moving_template_id != KEY_TEMPLATE
5252 && moving_template_id != PROPERTY_DEED_TEMPLATE,
5253 );
5254 if allow_drop {
5255 opts.push(MoveOption {
5256 label: "Drop on the ground".into(),
5257 kind: MoveOptionKind::Drop,
5258 });
5259 }
5260 opts.push(MoveOption {
5261 label: "Cancel".into(),
5262 kind: MoveOptionKind::Cancel,
5263 });
5264 opts
5265 }
5266
5267 fn is_same_container_dest(
5268 dest_location: &flatland_protocol::InventoryLocation,
5269 dest_parent: Option<uuid::Uuid>,
5270 from: &flatland_protocol::InventoryLocation,
5271 from_parent: Option<uuid::Uuid>,
5272 ) -> bool {
5273 dest_location == from && dest_parent == from_parent
5274 }
5275
5276 fn push_move_destination(
5277 opts: &mut Vec<MoveOption>,
5278 label: String,
5279 location: flatland_protocol::InventoryLocation,
5280 parent_instance_id: Option<uuid::Uuid>,
5281 from: &flatland_protocol::InventoryLocation,
5282 from_parent_instance_id: Option<uuid::Uuid>,
5283 ) {
5284 if Self::is_same_container_dest(
5285 &location,
5286 parent_instance_id,
5287 from,
5288 from_parent_instance_id,
5289 ) {
5290 return;
5291 }
5292 opts.push(MoveOption {
5293 label,
5294 kind: MoveOptionKind::Move {
5295 location,
5296 parent_instance_id,
5297 },
5298 });
5299 }
5300
5301 fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
5302 stack.capacity_volume.is_some_and(|c| c > 0.0)
5303 }
5304
5305 fn attaches_to_belt_loop(template_id: &str) -> bool {
5306 matches!(template_id, "leather_pouch" | "dimensional_pouch")
5307 }
5308
5309 fn append_nested_container_destinations(
5310 opts: &mut Vec<MoveOption>,
5311 location: flatland_protocol::InventoryLocation,
5312 container: &flatland_protocol::ItemStack,
5313 context: &str,
5314 from: &flatland_protocol::InventoryLocation,
5315 from_parent_instance_id: Option<uuid::Uuid>,
5316 moving_instance_id: Option<uuid::Uuid>,
5317 ) {
5318 for child in &container.contents {
5319 if Self::is_volume_container_stack(child)
5320 && child.item_instance_id != moving_instance_id
5321 {
5322 let name = child
5323 .display_name
5324 .clone()
5325 .unwrap_or_else(|| child.template_id.clone());
5326 Self::push_move_destination(
5327 opts,
5328 format!("{name} ({context})"),
5329 location.clone(),
5330 child.item_instance_id,
5331 from,
5332 from_parent_instance_id,
5333 );
5334 }
5335 let nested_context = format!(
5336 "in {}",
5337 child.display_name.as_deref().unwrap_or(&child.template_id)
5338 );
5339 Self::append_nested_container_destinations(
5340 opts,
5341 location.clone(),
5342 child,
5343 &nested_context,
5344 from,
5345 from_parent_instance_id,
5346 moving_instance_id,
5347 );
5348 }
5349 }
5350
5351 fn clamp_inventory_indices(&mut self) {
5352 let n = self.inventory_selectable_rows().len();
5353 self.inventory_menu_index = if n == 0 {
5354 0
5355 } else {
5356 self.inventory_menu_index.min(n - 1)
5357 };
5358 if let Some(picker) = &self.move_picker {
5359 let pn = picker.options.len();
5360 self.move_picker_index = if pn == 0 {
5361 0
5362 } else {
5363 self.move_picker_index.min(pn - 1)
5364 };
5365 }
5366 }
5367
5368 fn sync_interior_map_context(&mut self) {
5373 if self.effective_inside_building().is_none() {
5374 self.interior_map = None;
5375 if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
5376 self.z_platforms = platforms;
5377 self.z_transitions = transitions;
5378 }
5379 return;
5380 }
5381 self.sync_interior_z_bands();
5382 }
5383
5384 fn sync_interior_z_bands(&mut self) {
5386 if self.effective_inside_building().is_some() {
5387 if let Some(map) = &self.interior_map {
5388 if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
5389 if self.z_bands_outdoor_backup.is_none() {
5390 self.z_bands_outdoor_backup = Some((
5391 std::mem::take(&mut self.z_platforms),
5392 std::mem::take(&mut self.z_transitions),
5393 ));
5394 }
5395 self.z_platforms = map.z_platforms.clone();
5396 self.z_transitions = map.z_transitions.clone();
5397 }
5398 }
5399 }
5400 }
5401
5402 fn apply_snapshot_fields(
5403 &mut self,
5404 snapshot: &flatland_protocol::Snapshot,
5405 entity_id: EntityId,
5406 ) {
5407 self.tick = snapshot.tick;
5408 self.chunk_rev = snapshot.chunk_rev;
5409 self.content_rev = snapshot.content_rev;
5410 self.publish_rev = snapshot.publish_rev;
5411 self.resource_nodes = snapshot.resource_nodes.clone();
5412 self.ground_drops = snapshot.ground_drops.clone();
5413 self.placed_containers = snapshot.placed_containers.clone();
5414 self.world_x0 = snapshot.world_x0;
5415 self.world_y0 = snapshot.world_y0;
5416 self.world_width_m = snapshot.world_width_m;
5417 self.world_height_m = snapshot.world_height_m;
5418 self.world_clock = snapshot.world_clock;
5419 self.terrain_zones = snapshot.terrain_zones.clone();
5420 self.z_platforms = snapshot.z_platforms.clone();
5421 self.z_transitions = snapshot.z_transitions.clone();
5422 self.z_bands_outdoor_backup = None;
5424 self.buildings = snapshot.buildings.clone();
5425 self.doors = snapshot.doors.clone();
5426 self.interior_map = snapshot.interior_map.clone();
5427 self.npcs = snapshot.npcs.clone();
5428 self.blueprints = snapshot.blueprints.clone();
5429 self.building_materials = snapshot.building_materials.clone();
5430 self.sync_inventory_from_stacks(&snapshot.inventory);
5431 self.player = snapshot
5432 .entities
5433 .iter()
5434 .find(|e| e.id == entity_id)
5435 .cloned();
5436 self.entities = snapshot.entities.clone();
5437 self.quest_log = snapshot.quest_log.clone();
5438 self.apply_hired_workers(snapshot.hired_workers.clone());
5439 self.interactables = snapshot.interactables.clone();
5440 self.ledger = snapshot.ledger.clone();
5441 self.career = snapshot.career.clone();
5442 self.combat_fx = snapshot.combat_fx.clone();
5443 self.ground_hazards = snapshot.ground_hazards.clone();
5444 self.property_zones = snapshot.property_zones.clone();
5445 self.tax_zones = snapshot.tax_zones.clone();
5446 self.growth_zones = snapshot.growth_zones.clone();
5447 self.biome_zones = snapshot.biome_zones.clone();
5448 self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
5449 self.property_plots = snapshot.property_plots.clone();
5450 self.property_plot_settings = snapshot.property_plot_settings.clone();
5451 self.sync_item_catalog(&snapshot.item_catalog);
5452 if self.effective_inside_building().is_some() {
5455 self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
5456 }
5457 self.sync_interior_map_context();
5458 self.refresh_whisper_range();
5459 self.sync_gameplay_audio();
5460 }
5461
5462 fn refresh_inventory_ui(&mut self) {
5466 if let Some(picker) = &self.move_picker {
5467 let instance_id = picker.item_instance_id;
5468 let still_exists = self
5469 .inventory_selectable_rows()
5470 .iter()
5471 .any(|r| r.stack.item_instance_id == Some(instance_id));
5472 if !still_exists {
5473 self.move_picker = None;
5474 self.show_move_picker = false;
5475 }
5476 }
5477 if let Some(picker) = &self.destroy_picker {
5478 let instance_id = picker.item_instance_id;
5479 let still_exists = self
5480 .inventory_selectable_rows()
5481 .iter()
5482 .any(|r| r.stack.item_instance_id == Some(instance_id));
5483 if !still_exists {
5484 self.destroy_picker = None;
5485 self.show_destroy_picker = false;
5486 self.destroy_confirm_pending = false;
5487 }
5488 }
5489 self.clamp_inventory_indices();
5490 }
5491
5492 fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
5498 let selected_id = self
5499 .hired_workers
5500 .get(self.workers_menu_index)
5501 .map(|w| w.instance_id.clone());
5502 let previous_worker_ids: HashSet<String> = self
5503 .hired_workers
5504 .iter()
5505 .map(|worker| worker.instance_id.clone())
5506 .collect();
5507 workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
5508 let now = Instant::now();
5509 let saw_new_worker = workers
5510 .iter()
5511 .any(|worker| !previous_worker_ids.contains(&worker.instance_id));
5512 for worker in &workers {
5513 let was_hit = self
5514 .hired_workers
5515 .iter()
5516 .find(|previous| previous.instance_id == worker.instance_id)
5517 .is_some_and(|previous| {
5518 matches!(worker.mode, flatland_protocol::WorkerModeView::Defender)
5519 && worker.vitals.health_pct + 0.01 < previous.vitals.health_pct
5520 });
5521 if was_hit {
5522 self.worker_health_ring_until
5523 .insert(worker.entity_id, now + WORKER_HEALTH_RING_HOLD);
5524 }
5525 }
5526 let worker_entity_ids: HashSet<EntityId> =
5527 workers.iter().map(|worker| worker.entity_id).collect();
5528 self.worker_health_ring_until
5529 .retain(|entity_id, _| worker_entity_ids.contains(entity_id));
5530 for w in &workers {
5531 let prev_err = self
5532 .hired_workers
5533 .iter()
5534 .find(|p| p.instance_id == w.instance_id)
5535 .and_then(|p| p.last_error.as_deref());
5536 let new_err = w.last_error.as_deref();
5537 if new_err != prev_err {
5538 if let Some(err) = new_err {
5539 if !worker_error_is_transient(err) {
5540 self.push_log(format!("Worker {}: {err}", w.label));
5541 }
5542 }
5543 }
5544 }
5545 let mut next_display = BTreeMap::new();
5546 let mut next_errors = BTreeMap::new();
5547 for w in &workers {
5548 let mut sticky = self
5549 .worker_step_display
5550 .remove(&w.instance_id)
5551 .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
5552 sticky.observe(&w.step_label, now);
5553 next_display.insert(w.instance_id.clone(), sticky);
5554
5555 let mut err_sticky = self
5556 .worker_error_display
5557 .remove(&w.instance_id)
5558 .unwrap_or_default();
5559 err_sticky.observe(w.last_error.as_deref(), now);
5560 if err_sticky.shown(now).is_some() {
5561 next_errors.insert(w.instance_id.clone(), err_sticky);
5562 }
5563 }
5564 self.worker_step_display = next_display;
5565 self.worker_error_display = next_errors;
5566 self.hired_workers = workers;
5567 if saw_new_worker {
5568 self.pending_worker_hire_since = None;
5569 }
5570 self.sync_worker_take_picker_from_hired();
5571 if let Some(id) = selected_id {
5572 if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
5573 self.workers_menu_index = idx;
5574 return;
5575 }
5576 }
5577 if self.workers_menu_index >= self.hired_workers.len() {
5578 self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
5579 }
5580 }
5581
5582 fn sync_worker_take_picker_from_hired(&mut self) {
5584 if !self.show_worker_take_picker {
5585 return;
5586 }
5587 let Some(picker) = self.worker_take_picker.clone() else {
5588 return;
5589 };
5590 let Some(worker) = self
5591 .hired_workers
5592 .iter()
5593 .find(|w| w.instance_id == picker.worker_instance_id)
5594 .cloned()
5595 else {
5596 self.show_worker_take_picker = false;
5597 self.worker_take_picker = None;
5598 self.worker_take_picker_index = 0;
5599 return;
5600 };
5601 let options: Vec<WorkerGiveOption> = worker
5602 .inventory
5603 .iter()
5604 .filter_map(|stack| {
5605 let item_instance_id = stack.item_instance_id?;
5606 let label = stack
5607 .display_name
5608 .clone()
5609 .unwrap_or_else(|| stack.template_id.clone());
5610 let label = if stack.quantity > 1 {
5611 format!("{label} ×{}", stack.quantity)
5612 } else {
5613 label
5614 };
5615 Some(WorkerGiveOption {
5616 item_instance_id,
5617 label,
5618 quantity: stack.quantity,
5619 template_id: stack.template_id.clone(),
5620 })
5621 })
5622 .collect();
5623 if options.is_empty() {
5624 self.show_worker_take_picker = false;
5625 self.worker_take_picker = None;
5626 self.worker_take_picker_index = 0;
5627 return;
5628 }
5629 let prev_id = picker
5630 .options
5631 .get(self.worker_take_picker_index)
5632 .map(|o| o.item_instance_id);
5633 let idx = prev_id
5634 .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
5635 .unwrap_or(0)
5636 .min(options.len().saturating_sub(1));
5637 let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
5638 let quantity = picker.quantity.clamp(1, max_qty);
5639 self.worker_take_picker_index = idx;
5640 self.worker_take_picker = Some(WorkerTakePicker {
5641 worker_instance_id: picker.worker_instance_id,
5642 worker_label: picker.worker_label,
5643 options,
5644 quantity,
5645 });
5646 }
5647
5648 pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
5650 self.worker_step_display
5651 .get(worker_instance_id)
5652 .map(|s| s.shown.as_str())
5653 .or_else(|| {
5654 self.hired_workers
5655 .iter()
5656 .find(|w| w.instance_id == worker_instance_id)
5657 .map(|w| w.step_label.as_str())
5658 })
5659 .unwrap_or("")
5660 }
5661
5662 pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
5664 let now = Instant::now();
5665 self.worker_error_display
5666 .get(worker_instance_id)
5667 .and_then(|s| s.shown(now))
5668 .or_else(|| {
5669 self.hired_workers
5670 .iter()
5671 .find(|w| w.instance_id == worker_instance_id)
5672 .and_then(|w| w.last_error.as_deref())
5673 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
5674 })
5675 .filter(|e| !worker_error_is_hud_noise(e))
5676 }
5677
5678 fn apply_combat_hud(&mut self, combat: &CombatHud) {
5679 self.in_combat = combat.in_combat;
5680 self.auto_attack = combat.auto_attack;
5681 self.combat_has_los = combat.has_los;
5682 self.attack_cd_ticks = combat.attack_cd_ticks;
5683 self.gcd_ticks = combat.gcd_ticks;
5684 self.weapon_ability_id = combat.ability_id.clone();
5685 self.mainhand_template_id = combat.mainhand_template_id.clone();
5686 self.mainhand_label = combat.mainhand_label.clone();
5687 self.mainhand_instance_id = combat.mainhand_instance_id;
5688 self.offhand_template_id = combat.offhand_template_id.clone();
5689 self.offhand_label = combat.offhand_label.clone();
5690 self.offhand_instance_id = combat.offhand_instance_id;
5691 self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
5692 1
5693 } else {
5694 combat.mainhand_hand_slots
5695 };
5696 self.defense = combat.defense.clone();
5697 self.worn = combat.worn.iter().cloned().collect();
5698 self.carry_mass = combat.carry_mass;
5699 self.carry_mass_max = combat.carry_mass_max;
5700 self.encumbrance = combat.encumbrance;
5701 self.move_speed_mps = combat.move_speed_mps;
5702 self.move_speed_mult = combat.move_speed_mult;
5703 self.cast_progress = combat.cast.clone();
5704 self.timed_channel = combat.timed_channel.clone();
5705 if self.active_craft_channel().is_none() {
5706 self.craft_channel_blueprint_id = None;
5707 }
5708 self.plot_build_offer = combat.plot_build.clone();
5709 self.ability_cooldowns = combat.ability_cooldowns.clone();
5710 self.blocking_active = combat.blocking_active;
5711 self.max_target_slots = combat.max_target_slots.max(1);
5712 self.combat_slots = combat.slots.clone();
5713 self.rotation_presets = combat.rotation_presets.clone();
5714 self.known_abilities = combat.known_abilities.clone();
5715 self.ability_meta = combat
5716 .ability_meta
5717 .iter()
5718 .cloned()
5719 .map(|meta| (meta.id.clone(), meta))
5720 .collect();
5721 self.ability_mastery = combat
5722 .ability_mastery
5723 .iter()
5724 .cloned()
5725 .map(|row| (row.ability_id.clone(), row))
5726 .collect();
5727 self.hotbar = combat.hotbar.clone();
5728 self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
5729 self.keychain_stacks = combat.keychain.clone();
5730 self.whisper_pouch_stacks = combat.whisper_pouch.clone();
5731 self.combat_target_detail = combat.target.clone();
5732 self.statuses = combat.statuses.clone();
5733 self.combat_target = combat.target_entity_id;
5734 if combat.progression_xp_base > 0.0 {
5735 self.progression_curve = Some(flatland_protocol::ProgressionCurve {
5736 baseline_display: combat.progression_baseline,
5737 xp_base: combat.progression_xp_base,
5738 xp_growth: combat.progression_xp_growth,
5739 });
5740 }
5741 if let Some(xp) = &combat.progression_xp {
5742 if let Some(player) = &mut self.player {
5743 player.progression_xp = Some(xp.clone());
5744 if let Some(attrs) = combat.attributes {
5745 player.attributes = Some(attrs);
5746 }
5747 if let Some(skills) = &combat.skills {
5748 player.skills = Some(skills.clone());
5749 }
5750 }
5751 }
5752 if let Some(label) = &combat.target_label {
5753 self.combat_target_label = Some(label.clone());
5754 } else if let Some(id) = combat.target_entity_id {
5755 self.combat_target_label = self
5756 .entities
5757 .iter()
5758 .find(|e| e.id == id)
5759 .map(|e| e.label.clone())
5760 .or_else(|| self.combat_target_label.clone());
5761 }
5762 self.refresh_inventory_ui();
5763 }
5764
5765 pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
5767 self.combat_slots
5768 .iter()
5769 .find(|s| s.slot_index == slot)
5770 .and_then(|s| s.target_entity_id)
5771 .or_else(|| if slot == 1 { self.combat_target } else { None })
5772 }
5773
5774 pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
5776 self.ability_meta
5777 .get(ability_id)
5778 .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
5779 .unwrap_or(self.ground_target.is_some())
5782 }
5783
5784 pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
5786 self.ability_meta
5787 .get(ability_id)
5788 .map(|meta| meta.aim_mode == "ground")
5789 .unwrap_or(false)
5790 }
5791
5792 pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
5795 self.ability_meta
5796 .get(ability_id)
5797 .map(|meta| meta.auto_rotation_eligible)
5798 .unwrap_or(true)
5799 }
5800
5801 pub fn set_ground_target(&mut self, x: f32, y: f32) {
5803 self.ground_target = Some((x, y, 0.0));
5804 }
5805
5806 pub fn clear_ground_target(&mut self) {
5808 self.ground_target = None;
5809 }
5810
5811 pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
5814 if !(1..=9).contains(&slot_1_to_9) {
5815 return None;
5816 }
5817 self.hotbar
5818 .get((slot_1_to_9 - 1) as usize)
5819 .and_then(|a| a.as_deref())
5820 .filter(|id| !id.is_empty())
5821 }
5822
5823 pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
5825 let binding = self.hotbar_ability(slot_1_to_9)?;
5826 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
5827 let name = self
5828 .inventory_hints
5829 .get(template_id)
5830 .map(|h| h.display_name.as_str())
5831 .unwrap_or(template_id);
5832 let qty = self.inventory.get(template_id).copied().unwrap_or(0);
5833 Some(format!("{name}×{qty}"))
5834 } else {
5835 Some(binding.to_string())
5836 }
5837 }
5838
5839 pub fn loadout_ability_choices(&self) -> Vec<String> {
5841 let mut out = self.known_abilities.clone();
5842 let weapon = self.weapon_ability_id.trim();
5843 if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
5844 out.push(weapon.to_string());
5845 }
5846 out
5847 }
5848
5849 pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
5851 let mut out = Vec::new();
5852 for ability in self.loadout_ability_choices() {
5853 let meta = if ability == self.weapon_ability_id {
5854 Some("weapon".into())
5855 } else {
5856 None
5857 };
5858 out.push(LoadoutHotbarChoice {
5859 binding: ability.clone(),
5860 label: ability,
5861 meta,
5862 });
5863 }
5864 let mut consumables: Vec<(String, String, u32)> = Vec::new();
5865 for stack in &self.inventory_stacks {
5866 if Self::stack_is_item_grant(stack) {
5867 continue;
5868 }
5869 if Self::stack_is_blueprint_scroll(stack) {
5870 continue;
5871 }
5872 if self.inventory_item_category(&stack.template_id) != Some("consumable")
5873 && !Self::stack_is_serving(stack)
5874 {
5875 continue;
5876 }
5877 let qty = stack.quantity.max(1);
5878 if let Some((_, _, existing)) = consumables
5879 .iter_mut()
5880 .find(|(id, _, _)| id == &stack.template_id)
5881 {
5882 *existing = existing.saturating_add(qty);
5883 } else {
5884 let label = stack
5885 .display_name
5886 .clone()
5887 .or_else(|| {
5888 self.inventory_hints
5889 .get(&stack.template_id)
5890 .map(|h| h.display_name.clone())
5891 })
5892 .unwrap_or_else(|| stack.template_id.clone());
5893 consumables.push((stack.template_id.clone(), label, qty));
5894 }
5895 }
5896 consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
5897 for (template_id, label, qty) in consumables {
5898 out.push(LoadoutHotbarChoice {
5899 binding: flatland_protocol::hotbar_consumable_binding(&template_id),
5900 label: format!("{label} ×{qty}"),
5901 meta: Some("use".into()),
5902 });
5903 }
5904 out
5905 }
5906
5907 pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
5909 self.combat_candidates()
5910 }
5911
5912 pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
5914 let (px, py) = self.player_position();
5915 let dist = |id: EntityId| {
5916 self.entities
5917 .iter()
5918 .find(|e| e.id == id)
5919 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
5920 .unwrap_or(f32::MAX)
5921 };
5922
5923 let mut allies = Vec::new();
5924 if let Some(me) = self.player.as_ref() {
5926 let alive = me
5927 .vitals
5928 .as_ref()
5929 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
5930 .unwrap_or(true);
5931 if alive {
5932 allies.push((self.entity_id, "Yourself".into()));
5933 }
5934 }
5935 for entity in &self.entities {
5936 if entity.id == self.entity_id {
5937 continue;
5938 }
5939 if entity.vitals.is_some() {
5940 let alive = entity
5941 .vitals
5942 .as_ref()
5943 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
5944 .unwrap_or(true);
5945 if alive {
5946 allies.push((entity.id, entity.label.clone()));
5947 }
5948 }
5949 }
5950 allies.sort_by(|(a, _), (b, _)| {
5951 if *a == self.entity_id {
5952 return std::cmp::Ordering::Less;
5953 }
5954 if *b == self.entity_id {
5955 return std::cmp::Ordering::Greater;
5956 }
5957 dist(*a)
5958 .partial_cmp(&dist(*b))
5959 .unwrap_or(std::cmp::Ordering::Equal)
5960 });
5961
5962 let mut monsters = self.combat_candidates();
5963 monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
5964 allies.into_iter().chain(monsters).collect()
5965 }
5966
5967 fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
5968 match slot_index {
5969 2 => self.t2_candidates(),
5970 _ => self.t1_candidates(),
5971 }
5972 }
5973
5974 pub fn pick_combat_target_at(
5976 &self,
5977 wx: f32,
5978 wy: f32,
5979 slot_index: u8,
5980 radius_m: f32,
5981 ) -> Option<(EntityId, String)> {
5982 let mut best: Option<(f32, EntityId, String)> = None;
5983 for (id, label) in self.candidates_for_slot(slot_index) {
5984 let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
5985 if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
5987 let d = distance(wx, wy, npc.x, npc.y);
5988 if d <= radius_m {
5989 best = match best {
5990 Some((bd, _, _)) if bd <= d => best,
5991 _ => Some((d, id, label)),
5992 };
5993 }
5994 }
5995 continue;
5996 };
5997 let d = distance(
5998 wx,
5999 wy,
6000 entity.transform.position.x,
6001 entity.transform.position.y,
6002 );
6003 if d <= radius_m {
6004 best = match best {
6005 Some((bd, _, _)) if bd <= d => best,
6006 _ => Some((d, id, label)),
6007 };
6008 }
6009 }
6010 best.map(|(_, id, label)| (id, label))
6011 }
6012
6013 pub(crate) fn restore_from_welcome(
6015 &mut self,
6016 session_id: SessionId,
6017 entity_id: EntityId,
6018 snapshot: &flatland_protocol::Snapshot,
6019 ) {
6020 self.clear_harvest_state();
6021 self.disconnect_reason = None;
6022 self.show_stats = false;
6023 self.show_craft_menu = false;
6024 self.show_shop_menu = false;
6025 self.shop_catalog = None;
6026 self.show_inventory_menu = false;
6027 self.session_id = session_id;
6028 self.entity_id = entity_id;
6029 self.connected = true;
6030 self.apply_snapshot_fields(snapshot, entity_id);
6031 if let Some(combat) = &snapshot.combat {
6032 self.apply_combat_hud(combat);
6033 let stacks = self.inventory_stacks.clone();
6034 self.sync_inventory_from_stacks(&stacks);
6035 }
6036 }
6037
6038 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
6039 self.tick = delta.tick;
6040 self.world_clock = delta.world_clock;
6041
6042 if delta.entities.is_empty() {
6044 self.ground_drops = delta.ground_drops.clone();
6045 self.combat_fx = delta.combat_fx.clone();
6046 self.ground_hazards = delta.ground_hazards.clone();
6047 self.property_plots = delta.property_plots.clone();
6048 self.apply_terrain_overlays(&delta.terrain_overlays);
6049 if let Some(combat) = &delta.combat {
6050 self.apply_combat_hud(combat);
6051 let stacks = self.inventory_stacks.clone();
6052 self.sync_inventory_from_stacks(&stacks);
6053 }
6054 self.refresh_whisper_range();
6056 self.sync_gameplay_audio();
6057 return;
6058 }
6059 if !delta.buildings.is_empty() {
6060 self.buildings = delta.buildings.clone();
6061 }
6062 if !delta.blueprints.is_empty() {
6063 self.blueprints = delta.blueprints.clone();
6064 }
6065 if !delta.building_materials.is_empty() {
6066 self.building_materials = delta.building_materials.clone();
6067 }
6068 self.sync_inventory_from_stacks(&delta.inventory);
6069
6070 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
6071 self.player = Some(updated.clone());
6072 }
6073 self.entities = delta.entities.clone();
6074 if self.player.is_none() {
6075 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
6076 }
6077
6078 self.sync_interior_map_context();
6079
6080 if !delta.resource_nodes.is_empty() {
6084 self.resource_nodes = delta.resource_nodes.clone();
6085 } else if delta.interior_map.is_some() || self.effective_inside_building().is_some() {
6086 self.resource_nodes = delta.resource_nodes.clone();
6087 }
6088 self.ground_drops = delta.ground_drops.clone();
6089 self.placed_containers = delta.placed_containers.clone();
6091 if !delta.doors.is_empty() {
6092 self.doors = delta.doors.clone();
6093 }
6094 if self.effective_inside_building().is_some() {
6095 if let Some(map) = &delta.interior_map {
6096 self.interior_map = Some(map.clone());
6097 }
6098 } else {
6099 self.interior_map = None;
6100 }
6101 self.sync_interior_z_bands();
6102 self.npcs = delta.npcs.clone();
6104 if !delta.quest_log.is_empty() {
6105 self.quest_log = delta.quest_log.clone();
6106 }
6107 self.apply_hired_workers(delta.hired_workers.clone());
6108 if !delta.interactables.is_empty() {
6109 self.interactables = delta.interactables.clone();
6110 }
6111 if delta.ledger.is_some() {
6112 self.ledger = delta.ledger.clone();
6113 }
6114 if delta.career.is_some() {
6115 self.career = delta.career.clone();
6116 }
6117 self.combat_fx = delta.combat_fx.clone();
6118 self.ground_hazards = delta.ground_hazards.clone();
6119 if !delta.property_plots.is_empty() {
6121 self.property_plots = delta.property_plots.clone();
6122 }
6123 self.apply_terrain_overlays(&delta.terrain_overlays);
6124 if let Some(combat) = &delta.combat {
6125 self.apply_combat_hud(combat);
6126 let stacks = self.inventory_stacks.clone();
6127 self.sync_inventory_from_stacks(&stacks);
6128 } else {
6129 self.refresh_inventory_ui();
6130 }
6131 self.refresh_whisper_range();
6132 self.sync_gameplay_audio();
6133 }
6134
6135 fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
6138 self.terrain_zones.retain(|z| !z.id.starts_with("rt:"));
6139 self.terrain_zones.extend(overlays.iter().cloned());
6140 }
6141
6142 fn refresh_whisper_range(&mut self) {
6145 let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
6146 return;
6147 };
6148 let (px, py) = self.player_position();
6149 let in_range = self.entities.iter().any(|e| {
6150 e.id == peer
6151 && distance(px, py, e.transform.position.x, e.transform.position.y)
6152 <= INTERACTION_RADIUS_M
6153 });
6154 if !in_range {
6155 self.social_chat.cancel_whisper_out_of_range();
6156 }
6157 }
6158
6159 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
6161 let (px, py) = self.player_position();
6162 let mut out = Vec::new();
6163 for npc in &self.npcs {
6164 let Some(eid) = npc.entity_id else {
6165 continue;
6166 };
6167 let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
6168 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
6169 if alive && has_hp {
6170 out.push((eid, npc.label.clone()));
6171 }
6172 }
6173 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
6174 let dist = |id: EntityId| {
6175 self.entities
6176 .iter()
6177 .find(|e| e.id == id)
6178 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
6179 .unwrap_or(f32::MAX)
6180 };
6181 dist(*a_id)
6182 .partial_cmp(&dist(*b_id))
6183 .unwrap_or(std::cmp::Ordering::Equal)
6184 .then_with(|| a_label.cmp(b_label))
6185 .then_with(|| a_id.cmp(b_id))
6186 });
6187 out
6188 }
6189
6190 pub fn refresh_combat_target_label(&mut self) {
6191 let Some(id) = self.combat_target else {
6192 return;
6193 };
6194 if let Some((_, label)) = self
6195 .combat_candidates()
6196 .into_iter()
6197 .find(|(eid, _)| *eid == id)
6198 {
6199 self.combat_target_label = Some(label);
6200 } else if let Some(label) = self
6201 .entities
6202 .iter()
6203 .find(|e| e.id == id)
6204 .map(|e| e.label.clone())
6205 {
6206 self.combat_target_label = Some(label);
6207 }
6208 }
6209
6210 pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
6211 self.quest_log
6212 .iter()
6213 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
6214 .collect()
6215 }
6216
6217 pub fn has_worker_lodging(&self) -> bool {
6219 self.free_worker_lodging_slots() > 0
6220 }
6221
6222 pub fn free_worker_lodging_slots(&self) -> i64 {
6224 let slots: u32 = self
6225 .placed_containers
6226 .iter()
6227 .filter(|c| match (self.character_id, c.owner_character_id) {
6228 (Some(me), Some(owner)) => me == owner,
6229 (Some(_), None) => false,
6230 (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
6231 })
6232 .map(|c| c.worker_lodging_capacity.unwrap_or(0))
6233 .sum();
6234 let used = self.hired_workers.len() as u32;
6235 slots as i64 - used as i64
6236 }
6237
6238 pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
6240 let mut names: Vec<String> = self
6241 .hired_workers
6242 .iter()
6243 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
6244 .map(|w| w.label.clone())
6245 .collect();
6246 names.sort();
6247 names
6248 }
6249
6250 pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
6252 let is_lodging = self
6253 .placed_containers
6254 .iter()
6255 .find(|c| c.id == container_id)
6256 .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
6257 if !is_lodging {
6258 return None;
6259 }
6260 let names = self.lodging_occupant_labels(container_id);
6261 Some(if names.is_empty() {
6262 "vacant".into()
6263 } else {
6264 names.join(", ")
6265 })
6266 }
6267
6268 pub fn lodging_is_occupied(&self, container_id: &str) -> bool {
6270 matches!(
6271 self.lodging_occupancy_label(container_id),
6272 Some(label) if label != "vacant"
6273 )
6274 }
6275
6276 pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
6277 self.quest_log
6278 .iter()
6279 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
6280 .or_else(|| {
6281 self.quest_log
6282 .iter()
6283 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
6284 })
6285 }
6286
6287 pub fn nearby_lockable_door(&self) -> bool {
6289 let (px, py) = self.player_position();
6290 self.doors
6291 .iter()
6292 .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M)
6293 }
6294
6295 pub fn nearby_open_player_door(&self) -> bool {
6297 if self.effective_inside_building().is_some() {
6298 return false;
6299 }
6300 let (px, py) = self.player_position();
6301 self.doors.iter().any(|d| {
6302 if !d.open || d.locked {
6303 return false;
6304 }
6305 let player_house = self
6306 .buildings
6307 .iter()
6308 .find(|b| b.id == d.building_id)
6309 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6310 player_house && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M
6311 })
6312 }
6313
6314 pub fn nearby_player_exit_door(&self) -> bool {
6316 let Some(bid) = self.effective_inside_building() else {
6317 return false;
6318 };
6319 let (px, py) = self.player_position();
6320 self.doors.iter().any(|d| {
6321 if d.building_id != bid || d.portal.is_none() {
6322 return false;
6323 }
6324 let player_house = self
6325 .buildings
6326 .iter()
6327 .find(|b| b.id == d.building_id)
6328 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6329 player_house && (d.x - px).hypot(d.y - py) <= 1.5
6330 })
6331 }
6332
6333 pub fn nearest_interact_target(&self) -> Option<String> {
6335 let (px, py) = self.player_position();
6336 let inside = self.effective_inside_building();
6337
6338 #[derive(Clone, Copy, PartialEq, Eq)]
6339 enum Kind {
6340 Player,
6341 Npc,
6342 HiredWorker,
6343 QuestBoard,
6344 ExitDoor,
6345 EnterDoor,
6346 }
6347
6348 fn kind_class(kind: Kind) -> u8 {
6349 match kind {
6350 Kind::EnterDoor => 0,
6351 Kind::QuestBoard => 1,
6352 Kind::Player | Kind::Npc => 2,
6353 Kind::ExitDoor => 3,
6354 Kind::HiredWorker => 4,
6355 }
6356 }
6357
6358 fn kind_priority(kind: Kind) -> u8 {
6359 match kind {
6360 Kind::EnterDoor => 0,
6361 Kind::QuestBoard => 1,
6362 Kind::Player | Kind::Npc => 2,
6363 Kind::ExitDoor => 3,
6364 Kind::HiredWorker => 4,
6365 }
6366 }
6367
6368 let mut best: Option<(f32, Kind, String)> = None;
6369
6370 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
6371 if dist > max {
6372 return;
6373 }
6374 let replace = match best {
6375 None => true,
6376 Some((_, bk, _)) if kind_class(kind) < kind_class(bk) => true,
6377 Some((bd, bk, _))
6378 if kind_class(kind) == kind_class(bk) && dist < bd - 0.05 =>
6379 {
6380 true
6381 }
6382 Some((bd, bk, _))
6383 if kind_class(kind) == kind_class(bk) && (dist - bd).abs() <= 0.05 =>
6384 {
6385 kind_priority(kind) < kind_priority(bk)
6386 }
6387 _ => false,
6388 };
6389 if replace {
6390 best = Some((dist, kind, id));
6391 }
6392 };
6393
6394 for npc in &self.npcs {
6395 consider(
6396 distance(px, py, npc.x, npc.y),
6397 INTERACTION_RADIUS_M,
6398 Kind::Npc,
6399 npc.id.clone(),
6400 );
6401 }
6402
6403 for worker in &self.hired_workers {
6404 consider(
6405 distance(px, py, worker.x, worker.y),
6406 INTERACTION_RADIUS_M,
6407 Kind::HiredWorker,
6408 worker.instance_id.clone(),
6409 );
6410 }
6411
6412 for entity in &self.entities {
6413 if entity.id == self.entity_id
6414 || entity.vitals.is_none()
6415 || entity.label.trim().is_empty()
6416 {
6417 continue;
6418 }
6419 if self.hired_workers.iter().any(|w| w.entity_id == entity.id) {
6421 continue;
6422 }
6423 consider(
6424 distance(
6425 px,
6426 py,
6427 entity.transform.position.x,
6428 entity.transform.position.y,
6429 ),
6430 INTERACTION_RADIUS_M,
6431 Kind::Player,
6432 entity.id.to_string(),
6433 );
6434 }
6435
6436 for door in &self.doors {
6437 if let Some(ref bid) = inside {
6438 if door.building_id != *bid {
6439 continue;
6440 }
6441 let is_exit = door.portal.is_some();
6442 let max = if is_exit {
6443 INTERACTION_RADIUS_M
6444 } else {
6445 DOOR_INTERACTION_RADIUS_M
6446 };
6447 let kind = if is_exit {
6448 Kind::ExitDoor
6449 } else {
6450 Kind::EnterDoor
6451 };
6452 consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
6453 continue;
6454 }
6455 consider(
6456 distance(px, py, door.x, door.y),
6457 DOOR_INTERACTION_RADIUS_M,
6458 Kind::EnterDoor,
6459 door.id.clone(),
6460 );
6461 }
6462
6463 if inside.is_none() {
6464 for inter in &self.interactables {
6465 if inter.kind == "quest_board" {
6466 consider(
6467 distance(px, py, inter.x, inter.y),
6468 QUEST_BOARD_INTERACTION_RADIUS_M,
6469 Kind::QuestBoard,
6470 inter.id.clone(),
6471 );
6472 }
6473 }
6474 }
6475
6476 best.map(|(_, _, id)| id)
6477 }
6478
6479 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
6481 if self.effective_inside_building().is_some() {
6482 return None;
6483 }
6484 let (px, py) = self.player_position();
6485 self.interactables
6486 .iter()
6487 .filter(|i| i.kind == "quest_board")
6488 .map(|i| {
6489 let label = if i.label.is_empty() {
6490 "Quest board".to_string()
6491 } else {
6492 i.label.clone()
6493 };
6494 (label, distance(px, py, i.x, i.y))
6495 })
6496 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
6497 }
6498
6499 pub fn template_display_name(&self, template_id: &str) -> String {
6501 if let Some(name) = self
6502 .inventory_hints
6503 .get(template_id)
6504 .map(|h| h.display_name.clone())
6505 .filter(|n| !n.is_empty())
6506 {
6507 return name;
6508 }
6509 if let Some(entry) = self.item_catalog.get(template_id) {
6510 if !entry.display_name.trim().is_empty() {
6511 return entry.display_name.clone();
6512 }
6513 }
6514 humanize_template_id(template_id)
6515 }
6516
6517 pub fn catalog_entry(&self, template_id: &str) -> Option<&ItemCatalogEntryView> {
6518 self.item_catalog.get(template_id)
6519 }
6520
6521 pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
6523 if !display_name.is_empty() {
6524 display_name.to_string()
6525 } else {
6526 self.template_display_name(template_id)
6527 }
6528 }
6529
6530 pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
6531 self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
6532 }
6533
6534 pub fn blueprint_ingredient_label(
6535 &self,
6536 input: &flatland_protocol::BlueprintIngredientView,
6537 ) -> String {
6538 self.blueprint_item_label(&input.template_id, &input.display_name)
6539 }
6540
6541 pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
6542 self.blueprint_item_label(&tool.item, &tool.display_name)
6543 }
6544
6545 pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
6547 use crate::worker_route_editor::{
6548 node_candidates, node_candidates_stable, route_editor_lodging_anchor,
6549 };
6550 let lodging = self
6551 .worker_route_editor
6552 .as_ref()
6553 .and_then(|ed| ed.lodging_container_id.as_deref());
6554 match route_editor_lodging_anchor(lodging, &self.placed_containers) {
6555 Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
6556 None => node_candidates_stable(&self.resource_nodes),
6557 }
6558 }
6559
6560 pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
6561 if dist_m.is_nan() {
6562 return "—".into();
6563 }
6564 let from_bed = self
6565 .worker_route_editor
6566 .as_ref()
6567 .and_then(|ed| ed.lodging_container_id.as_deref())
6568 .and_then(|id| {
6569 self.placed_containers
6570 .iter()
6571 .find(|c| c.id == id)
6572 .map(|c| c.display_name.clone())
6573 });
6574 match from_bed {
6575 Some(bed) => format!("{dist_m:.0}m from {bed}"),
6576 None => format!("{dist_m:.0}m"),
6577 }
6578 }
6579
6580 pub fn placed_container_public_label(
6582 &self,
6583 c: &flatland_protocol::PlacedContainerView,
6584 ) -> String {
6585 let is_owner = match (self.character_id, c.owner_character_id) {
6586 (Some(me), Some(owner)) => me == owner,
6587 _ => false,
6588 };
6589 if is_owner {
6590 c.display_name.clone()
6591 } else {
6592 self.template_display_name(&c.template_id)
6593 }
6594 }
6595
6596 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
6598 let mut out = Vec::new();
6599 for stack in &self.inventory_stacks {
6600 if stack.template_id == KEY_TEMPLATE {
6601 out.push(KeychainEntry {
6602 stack: stack.clone(),
6603 stowed: false,
6604 });
6605 }
6606 }
6607 for stack in &self.keychain_stacks {
6608 if stack.template_id == KEY_TEMPLATE {
6609 out.push(KeychainEntry {
6610 stack: stack.clone(),
6611 stowed: true,
6612 });
6613 }
6614 }
6615 out
6616 }
6617
6618 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
6620 if stack.template_id != KEY_TEMPLATE {
6621 return None;
6622 }
6623 if let Some(name) = stack
6624 .props
6625 .get(PROP_OPENS_CONTAINER_NAME)
6626 .filter(|n| !n.is_empty())
6627 {
6628 return Some(name.clone());
6629 }
6630 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
6631 self.container_name_for_lock_id(opens)
6632 }
6633
6634 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
6636 if stack.template_id == KEY_TEMPLATE {
6637 self.template_display_name(KEY_TEMPLATE)
6638 } else {
6639 stack
6640 .display_name
6641 .clone()
6642 .unwrap_or_else(|| stack.template_id.clone())
6643 }
6644 }
6645
6646 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
6648 if stack.template_id != KEY_TEMPLATE {
6649 return String::new();
6650 }
6651 match self.key_pair_chest_label(stack) {
6652 Some(chest) if self.key_drop_blocked(stack) => {
6653 format!(" [key for {chest} — can't drop while locked]")
6654 }
6655 Some(chest) => format!(" [key for {chest}]"),
6656 None => " [key — unpaired]".into(),
6657 }
6658 }
6659
6660 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
6662 for c in &self.placed_containers {
6663 if c.lock_id.as_deref() == Some(lock) {
6664 return Some(c.display_name.clone());
6665 }
6666 }
6667 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
6668 self.worn
6669 .values()
6670 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
6671 })
6672 }
6673
6674 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
6676 if stack.template_id != KEY_TEMPLATE {
6677 return false;
6678 }
6679 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
6680 return false;
6681 };
6682 for c in &self.placed_containers {
6683 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
6684 return true;
6685 }
6686 }
6687 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
6688 return true;
6689 }
6690 self.worn
6691 .values()
6692 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
6693 }
6694
6695 pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
6697 stack.template_id == PROPERTY_DEED_TEMPLATE
6698 }
6699
6700 pub fn is_property_deed_template(template_id: &str) -> bool {
6701 template_id == PROPERTY_DEED_TEMPLATE
6702 }
6703
6704 pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
6705 stack
6706 .props
6707 .get("plot_id")
6708 .and_then(|s| uuid::Uuid::parse_str(s).ok())
6709 }
6710
6711 pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
6713 let (px, py) = self.player_position();
6714 let (cx, cy) = self.farm_plot_cell_under_player()?;
6715 let tx = cx as f32 + 0.5;
6716 let ty = cy as f32 + 0.5;
6717 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6718 return None;
6719 }
6720 let kind = self.terrain_at(tx, ty).or_else(|| self.terrain_at(px, py));
6721 if kind == Some(TerrainKindView::Tilled) {
6722 return None;
6723 }
6724 if matches!(
6725 kind,
6726 Some(TerrainKindView::ShallowWater)
6727 | Some(TerrainKindView::DeepWater)
6728 | Some(TerrainKindView::Rock)
6729 ) {
6730 return None;
6731 }
6732 Some((tx, ty))
6733 }
6734
6735 fn container_name_in_stacks(
6736 stacks: &[flatland_protocol::ItemStack],
6737 lock: &str,
6738 ) -> Option<String> {
6739 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
6740 for s in stacks {
6741 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
6742 return Some(GameState::stack_container_label(s));
6743 }
6744 if let Some(name) = walk(&s.contents, lock) {
6745 return Some(name);
6746 }
6747 }
6748 None
6749 }
6750 walk(stacks, lock)
6751 }
6752
6753 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
6754 stack
6755 .props
6756 .get(PROP_CUSTOM_NAME)
6757 .cloned()
6758 .or_else(|| stack.display_name.clone())
6759 .unwrap_or_else(|| stack.template_id.clone())
6760 }
6761
6762 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6763 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6764 for s in stacks {
6765 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
6766 return true;
6767 }
6768 if walk(&s.contents, lock) {
6769 return true;
6770 }
6771 }
6772 false
6773 }
6774 walk(stacks, lock)
6775 }
6776
6777 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
6778 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
6779 return Some(stack.clone());
6780 }
6781 for worn in self.worn.values() {
6782 if worn.item_instance_id == Some(instance_id) {
6783 return Some(worn.clone());
6784 }
6785 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
6786 return Some(stack.clone());
6787 }
6788 }
6789 None
6790 }
6791
6792 pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
6794 self.property_zones
6795 .iter()
6796 .enumerate()
6797 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6798 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6799 .map(|(_, z)| z)
6800 }
6801
6802 pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
6804 self.tax_zones
6805 .iter()
6806 .enumerate()
6807 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6808 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6809 .map(|(_, z)| z)
6810 }
6811
6812 pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
6814 let mut max_bps = 0u32;
6815 let mut y = y0 + 0.5;
6816 while y < y1 {
6817 let mut x = x0 + 0.5;
6818 while x < x1 {
6819 if let Some(tz) = self.tax_zone_at(x, y) {
6820 max_bps = max_bps.max(tz.rate_bps);
6821 }
6822 x += 1.0;
6823 }
6824 y += 1.0;
6825 }
6826 max_bps
6827 }
6828
6829 pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6831 let mode = self.claim_mode.as_ref()?;
6832 let w = mode.width_m.max(1) as f32;
6833 let h = mode.height_m.max(1) as f32;
6834 Some((
6835 mode.anchor_x,
6836 mode.anchor_y,
6837 mode.anchor_x + w,
6838 mode.anchor_y + h,
6839 ))
6840 }
6841
6842 pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6844 let mode = self.relocate_mode.as_ref()?;
6845 let x0 = mode.cursor_x.floor();
6846 let y0 = mode.cursor_y.floor();
6847 Some((x0, y0, x0 + 1.0, y0 + 1.0))
6848 }
6849
6850 pub fn claim_quote(&self) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
6853 let mode = self.claim_mode.as_ref()?;
6854 let zone = self.property_zones.iter().find(|z| z.id == mode.zone_id)?;
6855 let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
6856 let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
6857 let zone_area = zone_view_area_m2(zone).max(1.0);
6858 let area_frac = (area / zone_area).clamp(0.0, 1.0);
6859 let weight = self
6860 .property_plot_settings
6861 .as_ref()
6862 .map(|s| s.tax_premium_weight)
6863 .unwrap_or(0.5)
6864 .max(0.0);
6865 let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
6866 let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
6867 let purchase = ((zone.crown_price_copper as f64) * (area_frac as f64) * (premium as f64))
6868 .ceil()
6869 .max(0.0) as u64;
6870 let upkeep = if zone.upkeep_copper_per_day == 0 {
6871 0
6872 } else {
6873 ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
6874 .ceil()
6875 .max(1.0) as u64
6876 };
6877 let copper = crate::currency::copper_from_counts(&self.inventory);
6878 let can_afford = copper >= purchase;
6879 let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
6880 Some((purchase, upkeep, area, premium, can_afford, valid, reason))
6881 }
6882
6883 fn validate_claim_footprint(
6884 &self,
6885 zone: &flatland_protocol::PropertyZoneView,
6886 x0: f32,
6887 y0: f32,
6888 x1: f32,
6889 y1: f32,
6890 area: f32,
6891 ) -> (bool, String) {
6892 let min_area = self
6893 .property_plot_settings
6894 .as_ref()
6895 .map(|s| s.min_plot_area_m2)
6896 .unwrap_or(4.0);
6897 if area + f32::EPSILON < min_area {
6898 return (false, "plot too small".into());
6899 }
6900 if zone.max_area_m2.is_some_and(|m| area > m) {
6901 return (false, "plot exceeds max area".into());
6902 }
6903 if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
6904 return (false, "plot must lie inside the property zone".into());
6905 }
6906 if self
6907 .property_plots
6908 .iter()
6909 .any(|p| rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1))
6910 {
6911 return (false, "plot overlaps an existing claim".into());
6912 }
6913 (true, String::new())
6914 }
6915
6916 pub fn free_property_zone_under_player(&self) -> Option<&flatland_protocol::PropertyZoneView> {
6918 let (px, py) = self.player_position();
6919 let zone = self.property_zone_at(px, py)?;
6920 if self.property_plots.iter().any(|p| point_in_plot(px, py, p)) {
6921 return None;
6922 }
6923 Some(zone)
6924 }
6925
6926 pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
6928 let (px, py) = self.player_position();
6929 self.property_plots
6930 .iter()
6931 .find(|p| p.is_mine && point_in_plot(px, py, p))
6932 }
6933
6934 pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
6936 let (px, py) = self.player_position();
6937 self.property_plots
6938 .iter()
6939 .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
6940 }
6941
6942 pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
6944 if self.farmable_plot_under_player().is_none() {
6945 return None;
6946 }
6947 let (px, py) = self.player_position();
6948 Some((px.floor() as i32, py.floor() as i32))
6949 }
6950
6951 fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
6952 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6953 self.resource_nodes.iter().any(|n| {
6954 let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
6955 ncx == cx && ncy == cy || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
6956 })
6957 }
6958
6959 fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
6960 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6961 let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
6962 || self
6963 .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
6964 .is_some_and(|z| z.kind == TerrainKindView::Tilled);
6965 if !tilled {
6966 return false;
6967 }
6968 !self.resource_node_occupies_farm_cell(cx, cy)
6969 }
6970
6971 pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
6973 let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
6974 return false;
6975 };
6976 self.free_tilled_plant_slot_at(cx, cy)
6977 }
6978
6979 pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
6981 let (px, py) = self.player_position();
6982 for dy in -2..=2 {
6983 for dx in -2..=2 {
6984 let cx = px.floor() as i32 + dx;
6985 let cy = py.floor() as i32 + dy;
6986 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6987 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6988 continue;
6989 }
6990 if self.free_tilled_plant_slot_at(cx, cy) {
6991 return true;
6992 }
6993 }
6994 }
6995 false
6996 }
6997
6998 fn stack_is_farm_seed(&self, stack: &flatland_protocol::ItemStack) -> bool {
6999 if stack.quantity == 0 {
7000 return false;
7001 }
7002 if stack.props.contains_key("seed_for") {
7003 return true;
7004 }
7005 if let Some(entry) = self.item_catalog.get(&stack.template_id) {
7006 if entry.is_farm_seed() {
7007 return true;
7008 }
7009 }
7010 stack.template_id.ends_with("_seed")
7011 }
7012
7013 pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
7015 let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
7016 fn walk(
7017 stacks: &[flatland_protocol::ItemStack],
7018 state: &GameState,
7019 counts: &mut std::collections::HashMap<String, u32>,
7020 ) {
7021 for s in stacks {
7022 if state.stack_is_farm_seed(s) {
7023 *counts.entry(s.template_id.clone()).or_default() += s.quantity;
7024 }
7025 walk(&s.contents, state, counts);
7026 }
7027 }
7028 walk(&self.inventory_stacks, self, &mut counts);
7029 for worn in self.worn.values() {
7030 walk(std::slice::from_ref(worn), self, &mut counts);
7031 }
7032 let mut out: Vec<_> = counts
7033 .into_iter()
7034 .map(|(template_id, quantity)| {
7035 let label = self.template_display_name(&template_id);
7036 (template_id, quantity, label)
7037 })
7038 .collect();
7039 out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
7040 out
7041 }
7042
7043 pub fn first_farm_seed_template(&self) -> Option<String> {
7045 self.farm_seed_entries()
7046 .into_iter()
7047 .next()
7048 .map(|(id, _, _)| id)
7049 }
7050
7051 pub fn clamp_plant_menu(&mut self) {
7052 let n = self.farm_seed_entries().len();
7053 if n == 0 {
7054 self.plant_menu_index = 0;
7055 self.plant_quantity = 1;
7056 return;
7057 }
7058 self.plant_menu_index = self.plant_menu_index.min(n - 1);
7059 let max_qty = self
7060 .farm_seed_entries()
7061 .get(self.plant_menu_index)
7062 .map(|(_, q, _)| *q)
7063 .unwrap_or(1)
7064 .max(1);
7065 self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
7066 }
7067
7068 pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
7069 let entries = self.farm_seed_entries();
7070 let (id, max, label) = entries.get(self.plant_menu_index)?;
7071 let qty = self.plant_quantity.min(*max).max(1);
7072 Some((id.clone(), qty, label.clone()))
7073 }
7074
7075 pub fn location_context_lines(&self) -> Vec<ContextLine> {
7077 let (px, py) = self.player_position();
7078 let inside = self.effective_inside_building();
7079 let mut lines = Vec::new();
7080
7081 if let Some(kind) = self.terrain_at(px, py) {
7082 lines.push(ContextLine {
7083 on_top: true,
7084 text: format!("Terrain: {}", terrain_kind_label(kind)),
7085 });
7086 }
7087
7088 if let Some(id) = inside.as_ref() {
7089 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
7090 lines.push(ContextLine {
7091 on_top: true,
7092 text: format!("Inside: {}", b.label),
7093 });
7094 }
7095 }
7096
7097 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
7098
7099 for node in &self.resource_nodes {
7100 if node.id.starts_with("preview:") {
7101 continue;
7102 }
7103 let dist = distance(px, py, node.x, node.y);
7104 if dist > NEARBY_SCAN_M {
7105 continue;
7106 }
7107 let on_top = dist <= ON_TOP_RADIUS_M;
7108 let prefix = if on_top { "On" } else { "Near" };
7109 let name = resource_node_near_display_label(&node.label);
7110 let action = resource_node_near_action_suffix(node);
7111 nearby.push((
7112 dist,
7113 ContextLine {
7114 on_top,
7115 text: format!("{prefix}: {name} ({dist:.1}m){action}"),
7116 },
7117 ));
7118 }
7119
7120 for drop in &self.ground_drops {
7121 let dist = distance(px, py, drop.x, drop.y);
7122 if dist > INTERACTION_RADIUS_M {
7123 continue;
7124 }
7125 let on_top = dist <= ON_TOP_RADIUS_M;
7126 let name = self.template_display_name(&drop.template_id);
7127 let prefix = if on_top { "On" } else { "Near" };
7128 let qty = if drop.quantity > 1 {
7129 format!(" ×{}", drop.quantity)
7130 } else {
7131 String::new()
7132 };
7133 nearby.push((
7134 dist,
7135 ContextLine {
7136 on_top,
7137 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
7138 },
7139 ));
7140 }
7141
7142 for c in &self.placed_containers {
7143 if !self.placed_container_in_current_space(c) {
7144 continue;
7145 }
7146 let dist = distance(px, py, c.x, c.y);
7147 if dist > CONTAINER_RANGE_M {
7148 continue;
7149 }
7150 let on_top = dist <= ON_TOP_RADIUS_M;
7151 let name = self.placed_container_public_label(c);
7152 let lock = if c.locked { " [locked]" } else { "" };
7153 let prefix = if on_top { "On" } else { "Near" };
7154 nearby.push((
7155 dist,
7156 ContextLine {
7157 on_top,
7158 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
7159 },
7160 ));
7161 }
7162
7163 for npc in &self.npcs {
7164 let dist = distance(px, py, npc.x, npc.y);
7165 if dist > NEARBY_SCAN_M {
7166 continue;
7167 }
7168 let on_top = dist <= ON_TOP_RADIUS_M;
7169 let prefix = if on_top { "On" } else { "Near" };
7170 nearby.push((
7171 dist,
7172 ContextLine {
7173 on_top,
7174 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
7175 },
7176 ));
7177 }
7178
7179 for door in &self.doors {
7180 let dist = distance(px, py, door.x, door.y);
7181 if dist > DOOR_INTERACTION_RADIUS_M {
7182 continue;
7183 }
7184 let building = self
7185 .buildings
7186 .iter()
7187 .find(|b| b.id == door.building_id)
7188 .map(|b| b.label.as_str())
7189 .unwrap_or(door.building_id.as_str());
7190 let player_house = self
7191 .buildings
7192 .iter()
7193 .find(|b| b.id == door.building_id)
7194 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
7195 let action = if inside.is_some() && door.portal.is_some() {
7196 if player_house {
7197 if door.locked {
7198 "locked — l unlock · Enter exit".to_string()
7199 } else if door.open {
7200 "close · Enter exit · l lock".to_string()
7201 } else {
7202 "open · Enter exit · l lock".to_string()
7203 }
7204 } else {
7205 "exit".to_string()
7206 }
7207 } else if player_house {
7208 if door.locked {
7209 "locked — l unlock".to_string()
7210 } else if door.open {
7211 "close · Enter go inside · l lock".to_string()
7212 } else {
7213 "open · l lock".to_string()
7214 }
7215 } else {
7216 "enter".to_string()
7217 };
7218 nearby.push((
7219 dist,
7220 ContextLine {
7221 on_top: dist <= ON_TOP_RADIUS_M,
7222 text: format!("{building} door ({dist:.1}m) — f {action}"),
7223 },
7224 ));
7225 }
7226
7227 if inside.is_none() {
7228 for inter in &self.interactables {
7229 if inter.kind != "quest_board" {
7230 continue;
7231 }
7232 let dist = distance(px, py, inter.x, inter.y);
7233 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
7234 continue;
7235 }
7236 let on_top = dist <= ON_TOP_RADIUS_M;
7237 let prefix = if on_top { "On" } else { "Near" };
7238 let label = if inter.label.is_empty() {
7239 "Quest board".to_string()
7240 } else {
7241 inter.label.clone()
7242 };
7243 nearby.push((
7244 dist,
7245 ContextLine {
7246 on_top,
7247 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
7248 },
7249 ));
7250 }
7251 }
7252
7253 if self.near_liquid_fill_source() {
7254 let on_water = matches!(
7255 self.terrain_at(px, py),
7256 Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
7257 );
7258 let well = self.buildings.iter().find(|b| {
7259 b.tags.iter().any(|t| t == "well") && {
7260 let hw = b.width_m * 0.5;
7261 let hd = b.depth_m * 0.5;
7262 let nx = px.clamp(b.x - hw, b.x + hw);
7263 let ny = py.clamp(b.y - hd, b.y + hd);
7264 let dx = px - nx;
7265 let dy = py - ny;
7266 dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
7267 }
7268 });
7269 if let Some(well) = well {
7270 let name = if well.label.trim().is_empty() {
7271 "Well"
7272 } else {
7273 well.label.as_str()
7274 };
7275 nearby.push((
7276 0.0,
7277 ContextLine {
7278 on_top: true,
7279 text: format!("{name} — Use a vessel from inventory to fill"),
7280 },
7281 ));
7282 } else if on_water {
7283 if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
7284 line.text
7285 .push_str(" — Use a vessel from inventory to fill");
7286 }
7287 } else {
7288 nearby.push((
7289 0.0,
7290 ContextLine {
7291 on_top: true,
7292 text: "Water nearby — Use a vessel from inventory to fill".into(),
7293 },
7294 ));
7295 }
7296 }
7297
7298 if self.claim_mode.is_some() {
7299 nearby.push((
7300 0.0,
7301 ContextLine {
7302 on_top: true,
7303 text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
7304 .into(),
7305 },
7306 ));
7307 } else if let Some(plot) = self.my_plot_under_player() {
7308 let name = plot_public_label(plot);
7309 let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
7310 format!("{name} — f again to sell to crown")
7311 } else {
7312 format!(
7313 "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
7314 )
7315 };
7316 nearby.push((
7317 0.0,
7318 ContextLine {
7319 on_top: true,
7320 text: prompt,
7321 },
7322 ));
7323 } else if let Some(plot) = self.farmable_plot_under_player() {
7324 let name = plot_public_label(plot);
7325 let disc = if plot.farm_public {
7326 plot.public_tax_discount_bps / 100
7327 } else {
7328 plot.farm_allow
7329 .iter()
7330 .find(|g| Some(g.character_id) == self.character_id)
7331 .map(|g| g.tax_discount_bps / 100)
7332 .unwrap_or(0)
7333 };
7334 nearby.push((
7335 0.0,
7336 ContextLine {
7337 on_top: true,
7338 text: format!(
7339 "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
7340 ),
7341 },
7342 ));
7343 } else if let Some(zone) = self.free_property_zone_under_player() {
7344 let label = zone
7345 .label
7346 .as_deref()
7347 .filter(|s| !s.trim().is_empty())
7348 .unwrap_or(zone.id.as_str());
7349 nearby.push((
7350 0.0,
7351 ContextLine {
7352 on_top: true,
7353 text: format!("Claimable land: {label} — k buy plot"),
7354 },
7355 ));
7356 }
7357
7358 for entity in &self.entities {
7359 if entity.id == self.entity_id {
7360 continue;
7361 }
7362 let dist = distance(
7363 px,
7364 py,
7365 entity.transform.position.x,
7366 entity.transform.position.y,
7367 );
7368 if dist > NEARBY_SCAN_M {
7369 continue;
7370 }
7371 let label = if entity.label.is_empty() {
7372 format!("entity {}", entity.id)
7373 } else {
7374 entity.label.clone()
7375 };
7376 nearby.push((
7377 dist,
7378 ContextLine {
7379 on_top: dist <= ON_TOP_RADIUS_M,
7380 text: format!("Near: {label} ({dist:.1}m)"),
7381 },
7382 ));
7383 }
7384
7385 nearby.sort_by(|a, b| {
7386 a.0.partial_cmp(&b.0)
7387 .unwrap_or(std::cmp::Ordering::Equal)
7388 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
7389 });
7390 lines.extend(nearby.into_iter().map(|(_, l)| l));
7391
7392 if lines.is_empty() {
7393 lines.push(ContextLine {
7394 on_top: false,
7395 text: "(nothing notable nearby)".into(),
7396 });
7397 }
7398
7399 lines
7400 }
7401}
7402
7403#[derive(Debug, Clone)]
7405pub struct ContextLine {
7406 pub on_top: bool,
7407 pub text: String,
7408}
7409
7410const ON_TOP_RADIUS_M: f32 = 0.65;
7411const NEARBY_SCAN_M: f32 = 5.0;
7412
7413pub fn resource_node_near_display_label(label: &str) -> String {
7415 label
7416 .strip_suffix(" (growing)")
7417 .unwrap_or(label)
7418 .to_string()
7419}
7420
7421fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
7422 let t = label.trim();
7423 if t.is_empty() || t == id {
7424 return true;
7425 }
7426 let lower = t.to_ascii_lowercase();
7427 if lower.contains("_copy") {
7428 return true;
7429 }
7430 false
7431}
7432
7433fn humanize_item_template_label(template: &str) -> String {
7434 let base = template.rsplit('/').next().unwrap_or(template).trim();
7435 if base.is_empty() {
7436 return "Resource".into();
7437 }
7438 let stripped = base
7439 .strip_prefix("crop-")
7440 .or_else(|| base.strip_prefix("crop_"))
7441 .unwrap_or(base);
7442 stripped
7443 .split(|c: char| c == '-' || c == '_')
7444 .filter(|p| !p.is_empty())
7445 .map(|p| {
7446 let mut chars = p.chars();
7447 match chars.next() {
7448 Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
7449 None => String::new(),
7450 }
7451 })
7452 .collect::<Vec<_>>()
7453 .join(" ")
7454}
7455
7456pub fn resource_node_id_suffix(id: &str) -> String {
7458 let chars: Vec<char> = id
7459 .chars()
7460 .rev()
7461 .filter(|c| c.is_ascii_alphanumeric())
7462 .take(4)
7463 .collect();
7464 chars.into_iter().rev().collect()
7465}
7466
7467pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
7469 resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
7470}
7471
7472pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
7473 let cleaned = resource_node_near_display_label(label);
7474 let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
7475 cleaned
7476 } else if !item_template.trim().is_empty() {
7477 humanize_item_template_label(item_template)
7478 } else {
7479 id.to_string()
7480 };
7481 let suffix = resource_node_id_suffix(id);
7482 if suffix.is_empty() {
7483 friendly
7484 } else {
7485 format!("{friendly} ({suffix})")
7486 }
7487}
7488
7489pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
7491 use flatland_protocol::ResourceNodeState;
7492 if node.harvest_off {
7493 return " (decorative)".to_string();
7494 }
7495 if let Some(p) = node.growth_progress {
7496 if p < 1.0 - f32::EPSILON {
7497 let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
7498 return format!(" (growing, {pct}%)");
7499 }
7500 return " — f harvest".to_string();
7501 }
7502 match node.state {
7503 ResourceNodeState::Available => " — f harvest".to_string(),
7504 ResourceNodeState::Harvesting => " (being harvested)".to_string(),
7505 ResourceNodeState::Cooldown => " (depleted)".to_string(),
7506 }
7507}
7508
7509fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
7510 use flatland_protocol::TerrainKindView;
7511 match kind {
7512 TerrainKindView::Grass => "Grass",
7513 TerrainKindView::Dirt => "Dirt",
7514 TerrainKindView::Tilled => "Tilled",
7515 TerrainKindView::Desert => "Desert",
7516 TerrainKindView::Hill => "Hills",
7517 TerrainKindView::Bog => "Bog",
7518 TerrainKindView::Beach => "Beach",
7519 TerrainKindView::ShallowWater => "Shallow water",
7520 TerrainKindView::DeepWater => "Deep water",
7521 TerrainKindView::Trail => "Trail",
7522 TerrainKindView::Road => "Road",
7523 TerrainKindView::Rock => "Rock",
7524 }
7525}
7526
7527fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
7528 crate::world_zones::zone_rects_contain(rects, x, y)
7529}
7530
7531fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
7532 zone.rects
7533 .iter()
7534 .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
7535 .sum()
7536}
7537
7538fn claim_rect_fully_inside_zone(
7539 zone: &flatland_protocol::PropertyZoneView,
7540 x0: f32,
7541 y0: f32,
7542 x1: f32,
7543 y1: f32,
7544) -> bool {
7545 let mut y = y0 + 0.5;
7546 while y < y1 {
7547 let mut x = x0 + 0.5;
7548 while x < x1 {
7549 if !zone_rects_contain(&zone.rects, x, y) {
7550 return false;
7551 }
7552 x += 1.0;
7553 }
7554 y += 1.0;
7555 }
7556 true
7557}
7558
7559fn rects_overlap_half_open(
7560 ax0: f32,
7561 ay0: f32,
7562 ax1: f32,
7563 ay1: f32,
7564 bx0: f32,
7565 by0: f32,
7566 bx1: f32,
7567 by1: f32,
7568) -> bool {
7569 ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
7570}
7571
7572fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
7573 x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
7574}
7575
7576fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
7577 plot_public_label(p)
7578}
7579
7580fn plot_size_fallback_label(p: &flatland_protocol::PropertyPlotView) -> String {
7581 let w = (p.x1 - p.x0).abs();
7582 let d = (p.y1 - p.y0).abs();
7583 format!("Plot ({w:.0}×{d:.0} m)")
7584}
7585
7586pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
7588 let zone = p
7589 .zone_label
7590 .as_deref()
7591 .filter(|s| !s.trim().is_empty())
7592 .unwrap_or_else(|| {
7593 if p.property_zone_id.is_empty() {
7594 "Homestead"
7595 } else {
7596 p.property_zone_id.as_str()
7597 }
7598 });
7599 let label = if !p.label.trim().is_empty() {
7600 p.label.clone()
7601 } else if !p.plot_code.trim().is_empty() {
7602 p.plot_code.clone()
7603 } else {
7604 plot_size_fallback_label(p)
7605 };
7606 match p
7607 .owner_label
7608 .as_deref()
7609 .map(str::trim)
7610 .filter(|s| !s.is_empty())
7611 {
7612 Some(owner) => format!("{owner} — {zone} — {label}"),
7613 None => format!("{zone} — {label}"),
7614 }
7615}
7616
7617pub fn plot_stop_label(
7622 plots: &[flatland_protocol::PropertyPlotView],
7623 plot_id: uuid::Uuid,
7624) -> String {
7625 plots
7626 .iter()
7627 .find(|p| p.plot_id == plot_id)
7628 .map(plot_public_label)
7629 .unwrap_or_else(|| {
7630 let s = plot_id.to_string();
7631 format!("plot {}", s.get(..8).unwrap_or(s.as_str()))
7632 })
7633}
7634
7635fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
7637 let a = x0.min(x1).floor();
7638 let b = y0.min(y1).floor();
7639 let mut c = x0.max(x1).ceil();
7640 let mut d = y0.max(y1).ceil();
7641 if (c - a) < 1.0 {
7642 c = a + 1.0;
7643 }
7644 if (d - b) < 1.0 {
7645 d = b + 1.0;
7646 }
7647 (a, b, c, d)
7648}
7649
7650fn humanize_template_id(template_id: &str) -> String {
7651 if looks_like_template_uuid(template_id) {
7653 return "Unknown item".into();
7654 }
7655 template_id
7656 .split('_')
7657 .map(|word| {
7658 let mut chars = word.chars();
7659 match chars.next() {
7660 None => String::new(),
7661 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
7662 }
7663 })
7664 .collect::<Vec<_>>()
7665 .join(" ")
7666}
7667
7668fn looks_like_template_uuid(template_id: &str) -> bool {
7669 let bytes = template_id.as_bytes();
7670 if bytes.len() != 36 {
7671 return false;
7672 }
7673 let is_hex = |b: u8| b.is_ascii_hexdigit();
7674 let groups = [8usize, 4, 4, 4, 12];
7675 let mut i = 0;
7676 for (gi, &len) in groups.iter().enumerate() {
7677 if gi > 0 {
7678 if bytes.get(i) != Some(&b'-') {
7679 return false;
7680 }
7681 i += 1;
7682 }
7683 for _ in 0..len {
7684 if !bytes.get(i).copied().is_some_and(is_hex) {
7685 return false;
7686 }
7687 i += 1;
7688 }
7689 }
7690 true
7691}
7692
7693const HARVEST_RANGE_M: f32 = 1.5;
7695
7696pub struct GameClient<S: PlayConnection> {
7697 session: S,
7698 seq: Seq,
7699 pub state: GameState,
7700 last_move_forward: f32,
7701 last_move_strafe: f32,
7702}
7703
7704impl<S: PlayConnection> GameClient<S> {
7705 pub fn new(session: S) -> Self {
7706 let session_id = session.session_id();
7707 let entity_id = session.entity_id();
7708 let mut client = Self {
7709 session,
7710 seq: 0,
7711 last_move_forward: 0.0,
7712 last_move_strafe: 0.0,
7713 state: GameState {
7714 session_id,
7715 entity_id,
7716 character_id: None,
7717 tick: 0,
7718 chunk_rev: 0,
7719 content_rev: 0,
7720 publish_rev: 0,
7721 entities: Vec::new(),
7722 player: None,
7723 resource_nodes: Vec::new(),
7724 ground_drops: Vec::new(),
7725 placed_containers: Vec::new(),
7726 buildings: Vec::new(),
7727 doors: Vec::new(),
7728 interior_map: None,
7729 npcs: Vec::new(),
7730 blueprints: Vec::new(),
7731 building_materials: Vec::new(),
7732 world_x0: 0.0,
7733 world_y0: 0.0,
7734 world_width_m: 0.0,
7735 world_height_m: 0.0,
7736 terrain_zones: Vec::new(),
7737 z_platforms: Vec::new(),
7738 z_transitions: Vec::new(),
7739 z_bands_outdoor_backup: None,
7740 world_clock: flatland_protocol::WorldClock::default(),
7741 inventory: std::collections::HashMap::new(),
7742 inventory_hints: std::collections::HashMap::new(),
7743 item_catalog: std::collections::HashMap::new(),
7744 logs: VecDeque::new(),
7745 intents_sent: 0,
7746 ticks_received: 0,
7747 connected: false,
7748 disconnect_reason: None,
7749 show_stats: false,
7750 hud_log_hidden: false,
7751 show_equip_menu: false,
7752 equip_menu_index: 0,
7753 show_craft_menu: false,
7754 show_plot_build_menu: false,
7755 plot_build_focus_wall: true,
7756 plot_build_wall_index: 0,
7757 plot_build_roof_index: 0,
7758 craft_menu_index: 0,
7759 craft_batch_quantity: 1,
7760 craft_tab: CraftTab::Ready,
7761 craft_filter: String::new(),
7762 craft_filter_focused: false,
7763 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
7764 show_shop_menu: false,
7765 shop_catalog: None,
7766 bank_panel: None,
7767 bank_menu_index: 0,
7768 bank_ui_mode: BankUiMode::Menu,
7769 storage_panel: None,
7770 market_panel: None,
7771 market_menu_index: 0,
7772 market_filter: String::new(),
7773 market_filter_focused: false,
7774 market_category_filter: None,
7775 market_buy_confirm: None,
7776 market_ui_mode: MarketUiMode::Browse,
7777 storage_menu_index: 0,
7778 storage_ui_mode: StorageUiMode::Menu,
7779 shop_tab: ShopTab::default(),
7780 shop_menu_index: 0,
7781 shop_quantity: 1,
7782 shop_trade_log: VecDeque::new(),
7783 show_npc_verb_menu: false,
7784 npc_verb_target: None,
7785 npc_verb_index: 0,
7786 npc_verb_notice: None,
7787 player_verbs: crate::social::PlayerVerbState::default(),
7788 social_chat: crate::social::SocialChatState::default(),
7789 trade_ui: crate::social::TradeUiState::default(),
7790 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
7791 show_npc_chat: false,
7792 npc_chat: None,
7793 show_inventory_menu: false,
7794 inventory_menu_index: 0,
7795 inventory_tab: InventoryTab::OnPerson,
7796 inventory_filter: String::new(),
7797 inventory_filter_focused: false,
7798 show_move_picker: false,
7799 show_rename_prompt: false,
7800 rename_plot_id: None,
7801 highlighted_plot_id: None,
7802 show_worker_rename: false,
7803 rename_buffer: String::new(),
7804 move_picker_index: 0,
7805 move_picker: None,
7806 show_grant_picker: false,
7807 grant_picker_index: 0,
7808 grant_picker: None,
7809 show_destroy_picker: false,
7810 destroy_confirm_pending: false,
7811 destroy_picker: None,
7812 combat_target: None,
7813 combat_target_label: None,
7814 ground_target: None,
7815 combat_fx: Vec::new(),
7816 ground_hazards: Vec::new(),
7817 property_zones: Vec::new(),
7818 tax_zones: Vec::new(),
7819 growth_zones: Vec::new(),
7820 biome_zones: Vec::new(),
7821 terrain_kind_nav: Vec::new(),
7822 property_plots: Vec::new(),
7823 property_plot_settings: None,
7824 claim_mode: None,
7825 relocate_mode: None,
7826 sell_plot_confirm: None,
7827 sell_plot_armed_at: None,
7828 show_plant_menu: false,
7829 plant_menu_index: 0,
7830 show_farm_access: false,
7831 farm_access_name_draft: String::new(),
7832 farm_access_discount_bps: 0,
7833 farm_access_index: 0,
7834 plant_quantity: 1,
7835 in_combat: false,
7836 auto_attack: true,
7837 combat_has_los: false,
7838 attack_cd_ticks: 0,
7839 gcd_ticks: 0,
7840 weapon_ability_id: "unarmed".into(),
7841 mainhand_template_id: None,
7842 mainhand_label: None,
7843 mainhand_instance_id: None,
7844 offhand_template_id: None,
7845 offhand_label: None,
7846 offhand_instance_id: None,
7847 mainhand_hand_slots: 1,
7848 defense: None,
7849 worn: BTreeMap::new(),
7850 carry_mass: 0.0,
7851 carry_mass_max: 0.0,
7852 encumbrance: flatland_protocol::EncumbranceState::Light,
7853 move_speed_mps: 0.0,
7854 move_speed_mult: 0.0,
7855 inventory_stacks: Vec::new(),
7856 keychain_stacks: Vec::new(),
7857 whisper_pouch_stacks: Vec::new(),
7858 combat_target_detail: None,
7859 statuses: Vec::new(),
7860 cast_progress: None,
7861 timed_channel: None,
7862 plot_build_offer: None,
7863 ability_cooldowns: Vec::new(),
7864 blocking_active: false,
7865 max_target_slots: 1,
7866 combat_slots: Vec::new(),
7867 rotation_presets: Vec::new(),
7868 known_abilities: Vec::new(),
7869 ability_meta: std::collections::HashMap::new(),
7870 ability_mastery: std::collections::HashMap::new(),
7871 hotbar: vec![None; 9],
7872 max_abilities_per_rotation: 0,
7873 show_loadout_menu: false,
7874 show_keychain_menu: false,
7875 keychain_menu_index: 0,
7876 show_rotation_editor: false,
7877 loadout_menu_index: 0,
7878 loadout_hotbar_slot: 1,
7879 loadout_ability_index: 0,
7880 loadout_focus_presets: false,
7881 rotation_editor: RotationEditorState::default(),
7882 harvest_in_progress: false,
7883 harvest_started_at: None,
7884 pending_craft_ack: None,
7885 craft_channel_blueprint_id: None,
7886 pending_worker_job_ack: None,
7887 attending_worker_instance_id: None,
7888 quest_log: Vec::new(),
7889 interactables: Vec::new(),
7890 ledger: None,
7891 career: None,
7892 character_sheet_tab: CharacterSheetTab::Character,
7893 ledger_period: LedgerPeriod::Day,
7894 show_quest_offer: false,
7895 pending_quest_offers: Vec::new(),
7896 quest_offer_index: 0,
7897 show_quest_menu: false,
7898 quest_menu_index: 0,
7899 quest_withdraw_confirm: false,
7900 hired_workers: Vec::new(),
7901 show_workers_menu: false,
7902 workers_menu_index: 0,
7903 worker_dismiss_confirmation: None,
7904 workers_menu_compact: false,
7905 worker_step_display: BTreeMap::new(),
7906 worker_error_display: BTreeMap::new(),
7907 worker_health_ring_until: BTreeMap::new(),
7908 pending_worker_hire_since: None,
7909 show_worker_give_picker: false,
7910 worker_give_picker_index: 0,
7911 worker_give_picker: None,
7912 show_worker_give_target_picker: false,
7913 worker_give_target_picker_index: 0,
7914 worker_give_target_picker: None,
7915 show_worker_take_picker: false,
7916 worker_take_picker_index: 0,
7917 worker_take_picker: None,
7918 show_worker_teach_picker: false,
7919 worker_teach_picker_index: 0,
7920 worker_teach_picker: None,
7921 worker_route_editor: None,
7922 progression_curve: None,
7923 },
7924 };
7925 client.state.apply_client_ui_prefs();
7926 client
7927 }
7928
7929 pub fn entity_id(&self) -> EntityId {
7930 self.state.entity_id
7931 }
7932
7933 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
7934 if self.state.connected {
7935 return Ok(());
7936 }
7937
7938 loop {
7939 match self.session.next_event().await {
7940 Some(SessionEvent::Welcome {
7941 session_id,
7942 entity_id,
7943 snapshot,
7944 }) => {
7945 self.state
7946 .restore_from_welcome(session_id, entity_id, &snapshot);
7947 self.state.apply_client_ui_prefs();
7948 self.state.push_log(format!(
7949 "Connected — session {session_id}, entity {entity_id}"
7950 ));
7951 return Ok(());
7952 }
7953 Some(SessionEvent::Disconnected { .. }) => {
7954 anyhow::bail!("disconnected before welcome");
7955 }
7956 Some(_) => continue,
7957 None => anyhow::bail!("session closed before welcome"),
7958 }
7959 }
7960 }
7961
7962 pub fn drain_events(&mut self) {
7964 while let Some(event) = self.session.try_next_event() {
7965 if self.handle_event_sync(event).is_err() {
7966 break;
7967 }
7968 }
7969 }
7970
7971 pub async fn next_event(&mut self) -> Option<SessionEvent> {
7973 self.session.next_event().await
7974 }
7975
7976 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
7977 self.handle_event_sync(event)
7978 }
7979
7980 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
7981 match event {
7982 SessionEvent::Welcome {
7983 session_id,
7984 entity_id,
7985 snapshot,
7986 } => {
7987 let resumed = self.state.connected;
7988 self.state
7989 .restore_from_welcome(session_id, entity_id, &snapshot);
7990 if resumed {
7991 self.state.push_log(format!(
7992 "Session restored — session {session_id}, entity {entity_id}"
7993 ));
7994 }
7995 }
7996 SessionEvent::ContentUpdated { snapshot } => {
7997 self.state
7998 .apply_snapshot_fields(&snapshot, self.state.entity_id);
7999 self.state.push_log(format!(
8000 "World updated (content rev {})",
8001 snapshot.content_rev
8002 ));
8003 }
8004 SessionEvent::QuestCatalogUpdated(update) => {
8005 self.state.push_log(format!(
8006 "Quest board updated (revision {}, {} new, {} retired)",
8007 update.revision,
8008 update.accepted.len(),
8009 update.retired.len()
8010 ));
8011 }
8012 SessionEvent::Tick(delta) => {
8013 self.state.apply_tick_fields(&delta, self.state.entity_id);
8014 self.state.ticks_received += 1;
8015 }
8016 SessionEvent::IntentAck {
8017 entity_id,
8018 seq,
8019 tick,
8020 } => {
8021 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
8022 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
8023 if *craft_seq == seq {
8024 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
8025 if batches > 1 {
8026 self.state.push_log(format!("Crafting {label} ×{batches}…"));
8027 } else {
8028 self.state.push_log(format!("Crafting {label}…"));
8029 }
8030 }
8031 }
8032 if self
8033 .state
8034 .pending_worker_job_ack
8035 .as_ref()
8036 .is_some_and(|p| p.seq == seq)
8037 {
8038 let pending = self.state.pending_worker_job_ack.take().unwrap();
8039 if pending.idle {
8040 self.state.push_log(format!(
8041 "Route cleared for {} — worker idle",
8042 pending.worker_label
8043 ));
8044 } else {
8045 self.state.push_log(format!(
8046 "Route saved for {} — {} stop(s), job loop active",
8047 pending.worker_label, pending.stop_count
8048 ));
8049 }
8050 if self
8051 .state
8052 .worker_route_editor
8053 .as_ref()
8054 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
8055 {
8056 self.close_worker_route_editor();
8057 }
8058 }
8059 }
8060 SessionEvent::Chat(msg) => {
8061 let label = match msg.channel {
8062 flatland_protocol::ChatChannel::Nearby => "nearby",
8063 flatland_protocol::ChatChannel::Direct => "speak",
8064 flatland_protocol::ChatChannel::Whisper => "whisper",
8065 flatland_protocol::ChatChannel::WhisperStone => "stone",
8066 };
8067 let clarity = match msg.clarity {
8068 flatland_protocol::ChatClarity::Clear => "",
8069 flatland_protocol::ChatClarity::Partial => "~",
8070 flatland_protocol::ChatClarity::Heavy => "…",
8071 };
8072 self.state.push_log(format!(
8073 "[{label}{clarity}] {}: {}",
8074 msg.from_name, msg.text
8075 ));
8076 let now_ms = std::time::SystemTime::now()
8077 .duration_since(std::time::UNIX_EPOCH)
8078 .map(|d| d.as_millis() as u64)
8079 .unwrap_or(0);
8080 self.state
8081 .social_chat
8082 .note_speech(&msg, self.state.entity_id, now_ms);
8083 self.state
8084 .social_chat
8085 .push(crate::social::ChatLogEntry::from_message(
8086 msg,
8087 self.state.entity_id,
8088 ));
8089 }
8090 SessionEvent::TradeOpened(panel) => {
8091 self.state.social_chat.pending_trade = None;
8092 let peer = panel.peer_name.clone();
8093 self.state.trade_ui.open(panel);
8094 self.state.social_chat.push_system(format!(
8095 "Trade open with {peer} — p present · r ready · Esc cancel"
8096 ));
8097 self.state
8098 .social_chat
8099 .push_cue(crate::social::AudioCue::TradeOpened);
8100 }
8101 SessionEvent::TradeClosed { reason } => {
8102 self.state.push_log(reason.clone());
8103 self.state.social_chat.push_system(reason);
8104 self.state.trade_ui.close();
8105 }
8106 SessionEvent::HarvestResult(result) => {
8107 self.state.clear_harvest_state();
8108 crate::harvest_trace!(
8109 entity_id = self.state.entity_id,
8110 node_id = %result.node_id,
8111 template = %result.item_template,
8112 quantity = result.quantity,
8113 client_tick = self.state.tick,
8114 "client applied harvest result"
8115 );
8116 let msg = if result.quantity == 0 {
8117 format!(
8118 "Harvested {} x0 — nothing dropped (loot table rolled empty)",
8119 result.item_template
8120 )
8121 } else {
8122 format!(
8123 "Harvested {} x{} (on the ground — press P to pick up)",
8124 result.item_template, result.quantity
8125 )
8126 };
8127 self.state.push_log(msg);
8128 }
8129 SessionEvent::CraftResult(result) => {
8130 for stack in &result.consumed {
8131 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
8132 *qty = qty.saturating_sub(stack.quantity);
8133 if *qty == 0 {
8134 self.state.inventory.remove(&stack.template_id);
8135 }
8136 }
8137 }
8138 for stack in &result.outputs {
8139 *self
8140 .state
8141 .inventory
8142 .entry(stack.template_id.clone())
8143 .or_insert(0) += stack.quantity;
8144 }
8145 self.state.craft_record_completed(&result.blueprint_id);
8146 if let Some(output) = result.outputs.first() {
8147 if result.batch_total > 1 {
8148 self.state.push_log(format!(
8149 "Crafted {} x{} ({}/{})",
8150 output.template_id,
8151 output.quantity,
8152 result.batch_index,
8153 result.batch_total
8154 ));
8155 } else {
8156 self.state.push_log(format!(
8157 "Crafted {} x{}",
8158 output.template_id, output.quantity
8159 ));
8160 }
8161 } else {
8162 self.state
8163 .push_log(format!("Craft finished: {}", result.blueprint_id));
8164 }
8165 }
8166 SessionEvent::Death(notice) => {
8167 self.state.clear_harvest_state();
8168 self.state.push_log(notice.message.clone());
8169 self.state.push_log(format!(
8170 "Respawned at ({:.1}, {:.1})",
8171 notice.respawn_x, notice.respawn_y
8172 ));
8173 }
8174 SessionEvent::Interaction(notice) => {
8175 if notice.message.starts_with("Harvest failed:") {
8176 self.state.clear_harvest_state();
8177 }
8178 if notice.message.starts_with("Can't do that:") {
8179 self.state.pending_worker_hire_since = None;
8180 self.state.pending_craft_ack = None;
8181 self.state.craft_channel_blueprint_id = None;
8182 if let Some(pending) = self.state.pending_worker_job_ack.take() {
8183 if let Some(w) = self
8184 .state
8185 .hired_workers
8186 .iter_mut()
8187 .find(|w| w.instance_id == pending.worker_instance_id)
8188 {
8189 w.route = pending.prev_route;
8190 w.mode = pending.prev_mode;
8191 w.step_label = pending.prev_step_label;
8192 w.last_error = pending.prev_last_error;
8193 }
8194 let reason = notice
8195 .message
8196 .strip_prefix("Can't do that:")
8197 .unwrap_or(¬ice.message)
8198 .trim();
8199 self.state.push_log(format!(
8200 "Route save failed for {}: {reason}",
8201 pending.worker_label
8202 ));
8203 }
8204 let reason = notice
8205 .message
8206 .strip_prefix("Can't do that:")
8207 .unwrap_or(¬ice.message)
8208 .trim();
8209 if reason.contains("already tilled") {
8210 if let Some(plot) = self.state.my_plot_under_player() {
8211 self.state.sell_plot_confirm = Some(plot.plot_id);
8212 self.state.sell_plot_armed_at = Some(Instant::now());
8213 }
8214 }
8215 }
8216 if notice.message.starts_with("Cast failed:") {
8217 self.state.cast_progress = None;
8218 }
8219 if notice.message.contains("slain the") {
8220 self.state.combat_target = None;
8221 self.state.combat_target_label = None;
8222 }
8223 if notice.message.contains("wants to trade") {
8225 if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
8226 let from_name = notice
8227 .message
8228 .split(" wants to trade")
8229 .next()
8230 .unwrap_or("Player")
8231 .to_string();
8232 self.state.social_chat.pending_trade =
8233 Some(crate::social::PendingTradeRequest {
8234 from_entity,
8235 from_name: from_name.clone(),
8236 });
8237 self.state.social_chat.push_system(format!(
8238 "{from_name} wants to trade — [Y] accept · [N] decline"
8239 ));
8240 self.state
8241 .social_chat
8242 .push_cue(crate::social::AudioCue::TradeOffer);
8243 }
8244 }
8245 if notice.message.starts_with("trade request declined") {
8246 self.state.social_chat.push_system(notice.message.clone());
8247 self.state
8248 .social_chat
8249 .push_cue(crate::social::AudioCue::TradeDeclined);
8250 }
8251 if self.state.show_npc_verb_menu && !notice.message.trim().is_empty() {
8253 self.state.npc_verb_notice = Some(notice.message.clone());
8254 self.state
8255 .social_chat
8256 .push_cue(crate::social::AudioCue::UiError);
8257 }
8258 self.state.apply_interaction_notice(¬ice);
8259 self.state.push_log(notice.message.clone());
8260 }
8261 SessionEvent::ShopOpened(catalog) => {
8262 self.state.apply_shop_catalog(catalog);
8263 }
8264 SessionEvent::BankOpened(panel) => {
8265 self.state.apply_bank_panel(panel);
8266 }
8267 SessionEvent::StorageOpened(panel) => {
8268 self.state.apply_storage_panel(panel);
8269 }
8270 SessionEvent::MarketOpened(panel) => {
8271 self.state.apply_market_panel(panel);
8272 }
8273 SessionEvent::NpcTalkOpened(opened) => {
8274 self.state.show_npc_verb_menu = false;
8275 if self.state.npc_verb_target.is_none() {
8276 self.state.npc_verb_target = Some(opened.npc_id.clone());
8277 }
8278 let label = opened.npc_label.clone();
8279 let banner = if !opened.trade_allowed {
8280 Some("Trade is unavailable right now.".to_string())
8281 } else {
8282 None
8283 };
8284 self.state.show_npc_chat = true;
8285 self.state.npc_chat = Some(NpcChatState {
8286 npc_id: opened.npc_id,
8287 npc_label: opened.npc_label,
8288 lines: if opened.greeting.is_empty() {
8289 vec![]
8290 } else {
8291 vec![format!("{label}: {}", opened.greeting)]
8292 },
8293 input: String::new(),
8294 pending: opened.greeting.is_empty(),
8295 talk_depth: opened.talk_depth,
8296 trade_allowed: opened.trade_allowed,
8297 banner,
8298 suggested_topics: opened.suggested_topics,
8299 });
8300 }
8301 SessionEvent::NpcTalkPending(_) => {
8302 if let Some(chat) = self.state.npc_chat.as_mut() {
8303 chat.pending = true;
8304 }
8305 }
8306 SessionEvent::NpcTalkReply(reply) => {
8307 if let Some(chat) = self.state.npc_chat.as_mut() {
8308 if chat.npc_id == reply.npc_id {
8309 chat.pending = false;
8310 if reply.trade_disabled {
8311 chat.trade_allowed = false;
8312 chat.banner = Some("Trade is unavailable right now.".to_string());
8313 }
8314 if reply.wind_down {
8315 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
8316 if chat.banner.is_none() {
8317 chat.banner =
8318 Some("They're wrapping up — keep it brief.".to_string());
8319 }
8320 }
8321 chat.lines
8322 .push(format!("{}: {}", chat.npc_label, reply.line));
8323 }
8324 }
8325 }
8326 SessionEvent::NpcTalkClosed(closed) => {
8327 if self
8328 .state
8329 .npc_chat
8330 .as_ref()
8331 .is_some_and(|c| c.npc_id == closed.npc_id)
8332 {
8333 self.state.show_npc_chat = false;
8334 self.state.npc_chat = None;
8335 }
8336 }
8337 SessionEvent::NpcTalkError(err) => {
8338 self.state.push_log(format!("Talk failed: {}", err.reason));
8339 if let Some(chat) = self.state.npc_chat.as_mut() {
8340 chat.pending = false;
8341 }
8342 }
8343 SessionEvent::UseResult(result) => {
8344 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
8347 *qty = qty.saturating_sub(1);
8348 if *qty == 0 {
8349 self.state.inventory.remove(&result.template_id);
8350 }
8351 }
8352 }
8353 SessionEvent::QuestOffer(offer) => {
8354 let title = offer.title.clone();
8355 self.state.push_quest_offer(offer);
8356 self.state.push_log(format!("Quest offered: {title}"));
8357 }
8358 SessionEvent::QuestAccepted(notice) => {
8359 self.state.remove_quest_offer(¬ice.quest_id);
8360 self.state.push_log(notice.message);
8361 }
8362 SessionEvent::QuestWithdrawn(notice) => {
8363 self.state.show_quest_menu = false;
8364 self.state.quest_withdraw_confirm = false;
8365 self.state.push_log(notice.message);
8366 }
8367 SessionEvent::QuestStepCompleted(notice) => {
8368 self.state.push_log(notice.message);
8369 }
8370 SessionEvent::QuestCompleted(notice) => {
8371 self.state.push_log(notice.message);
8372 }
8373 SessionEvent::Disconnected { reason } => {
8374 self.state.clear_harvest_state();
8375 self.state.connected = false;
8376 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
8377 if let Some(r) = &self.state.disconnect_reason {
8378 self.state.push_log(format!("Disconnected: {r}"));
8379 } else {
8380 self.state.push_log("Disconnected from server");
8381 }
8382 }
8383 }
8384 Ok(())
8385 }
8386
8387 pub fn is_connected(&self) -> bool {
8388 self.state.connected
8389 }
8390
8391 pub fn close_overlays(&mut self) {
8392 self.state.show_stats = false;
8393 self.state.show_craft_menu = false;
8394 self.state.show_plot_build_menu = false;
8395 self.state.show_shop_menu = false;
8396 self.state.shop_catalog = None;
8397 self.state.show_npc_verb_menu = false;
8398 self.state.npc_verb_target = None;
8399 self.state.show_npc_chat = false;
8400 self.state.npc_chat = None;
8401 self.state.show_inventory_menu = false;
8402 self.state.show_loadout_menu = false;
8403 self.state.show_rotation_editor = false;
8404 self.state.rotation_editor.reset();
8405 self.state.show_rename_prompt = false;
8406 self.state.show_worker_rename = false;
8407 self.state.rename_buffer.clear();
8408 self.state.show_move_picker = false;
8409 self.state.move_picker = None;
8410 self.state.show_destroy_picker = false;
8411 self.state.destroy_confirm_pending = false;
8412 self.state.destroy_picker = None;
8413 self.state.show_quest_offer = false;
8414 self.state.clear_quest_offers();
8415 self.state.show_quest_menu = false;
8416 self.state.quest_withdraw_confirm = false;
8417 self.state.show_workers_menu = false;
8418 self.close_worker_give_picker();
8419 self.close_worker_give_target_picker();
8420 self.close_worker_take_picker();
8421 self.close_worker_teach_picker();
8422 self.state.worker_route_editor = None;
8423 self.state.claim_mode = None;
8424 self.state.relocate_mode = None;
8425 self.state.sell_plot_confirm = None;
8426 self.state.sell_plot_armed_at = None;
8427 self.close_farm_access_panel();
8428 if self.state.show_plant_menu {
8429 self.close_plant_menu();
8430 }
8431 }
8432
8433 pub fn back_on_esc(&mut self) -> bool {
8435 if self.state.social_chat.composer_open() {
8436 self.state.social_chat.close_composer();
8437 return true;
8438 }
8439 if self.state.player_verbs.open {
8440 self.state.player_verbs.close();
8441 return true;
8442 }
8443 if self.state.whisper_pouch_ui.open {
8444 self.state.whisper_pouch_ui.open = false;
8445 return true;
8446 }
8447 if self.state.trade_ui.panel.is_some() {
8448 self.state.trade_ui.close();
8450 return true;
8451 }
8452 if self.state.show_rename_prompt {
8453 self.cancel_rename_prompt();
8454 return true;
8455 }
8456 if self.state.show_worker_rename {
8457 self.cancel_worker_rename();
8458 return true;
8459 }
8460 if self.state.show_destroy_picker {
8461 if self.state.destroy_confirm_pending {
8462 self.cancel_destroy_confirm();
8463 } else {
8464 self.close_destroy_picker();
8465 }
8466 return true;
8467 }
8468 if self.state.claim_mode.is_some() {
8469 self.cancel_claim_mode();
8470 return true;
8471 }
8472 if self.state.relocate_mode.is_some() {
8473 self.cancel_relocate_mode();
8474 return true;
8475 }
8476 if self.state.show_plant_menu {
8477 self.close_plant_menu();
8478 return true;
8479 }
8480 if self.state.show_farm_access {
8481 self.close_farm_access_panel();
8482 return true;
8483 }
8484 if self.state.sell_plot_confirm.is_some() {
8485 self.state.sell_plot_confirm = None;
8486 self.state.sell_plot_armed_at = None;
8487 self.state.push_log("Sell cancelled");
8488 return true;
8489 }
8490 if self.state.show_move_picker {
8491 self.close_move_picker();
8492 return true;
8493 }
8494 if self.state.show_rotation_editor {
8495 match self.state.rotation_editor.mode {
8496 RotationEditorMode::List => {
8497 self.state.show_rotation_editor = false;
8498 self.state.rotation_editor.reset();
8499 }
8500 RotationEditorMode::EditLabel => {
8501 self.state.rotation_editor.label_buffer.clear();
8502 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8503 }
8504 RotationEditorMode::PickAbility => {
8505 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8506 }
8507 RotationEditorMode::EditSequence => {
8508 self.state.rotation_editor.draft = None;
8509 self.state.rotation_editor.mode = RotationEditorMode::List;
8510 }
8511 }
8512 return true;
8513 }
8514 if self.state.show_inventory_menu {
8515 self.close_inventory_menu();
8516 return true;
8517 }
8518 if self.state.show_craft_menu {
8519 self.close_craft_menu();
8520 return true;
8521 }
8522 if self.state.show_plot_build_menu {
8523 self.close_plot_build_menu();
8524 return true;
8525 }
8526 if self.state.show_keychain_menu {
8527 self.close_keychain_menu();
8528 return true;
8529 }
8530 if self.state.show_quest_offer {
8531 self.quest_offer_decline();
8532 return true;
8533 }
8534 if self.state.show_shop_menu {
8535 return false;
8537 }
8538 if self.state.bank_panel.is_some() {
8539 return false;
8540 }
8541 if self.state.storage_panel.is_some() {
8542 return false;
8543 }
8544 if self.state.market_panel.is_some() {
8545 return false;
8546 }
8547 if self.state.show_npc_chat {
8548 return false;
8550 }
8551 if self.state.show_npc_verb_menu {
8552 self.state.show_npc_verb_menu = false;
8553 self.state.npc_verb_target = None;
8554 self.state.npc_verb_notice = None;
8555 return true;
8556 }
8557 if self.state.show_quest_menu {
8558 if self.state.quest_withdraw_confirm {
8559 self.state.quest_withdraw_confirm = false;
8560 } else {
8561 self.state.show_quest_menu = false;
8562 }
8563 return true;
8564 }
8565 if self.state.worker_route_editor.is_some() {
8566 if self.re_at_root_sheet() {
8568 let reopen = self.state.attending_worker_instance_id.clone();
8569 self.close_worker_route_editor();
8570 if let Some(id) = reopen {
8571 if let Some(idx) = self
8572 .state
8573 .hired_workers
8574 .iter()
8575 .position(|w| w.instance_id == id)
8576 {
8577 self.state.workers_menu_index = idx;
8578 self.state.show_workers_menu = true;
8579 }
8580 }
8581 } else {
8582 self.re_sheet_back();
8583 }
8584 return true;
8585 }
8586 if self.state.show_worker_give_picker {
8587 self.close_worker_give_picker();
8588 return true;
8589 }
8590 if self.state.show_worker_give_target_picker {
8591 self.close_worker_give_target_picker();
8592 return true;
8593 }
8594 if self.state.show_worker_take_picker {
8595 self.close_worker_take_picker();
8596 return true;
8597 }
8598 if self.state.show_worker_teach_picker {
8599 self.close_worker_teach_picker();
8600 return true;
8601 }
8602 if self.state.show_workers_menu {
8603 self.close_workers_menu_ui();
8604 return true;
8605 }
8606 if self.state.show_loadout_menu {
8607 self.state.show_loadout_menu = false;
8608 return true;
8609 }
8610 if self.state.show_stats {
8611 self.state.show_stats = false;
8612 return true;
8613 }
8614 if self.state.show_equip_menu {
8615 self.state.show_equip_menu = false;
8616 return true;
8617 }
8618 false
8619 }
8620
8621 pub fn toggle_stats(&mut self) {
8622 self.state.show_stats = !self.state.show_stats;
8623 if self.state.show_stats {
8624 self.state.character_sheet_tab = CharacterSheetTab::Character;
8625 self.state.show_craft_menu = false;
8626 self.state.show_shop_menu = false;
8627 self.state.shop_catalog = None;
8628 self.state.show_inventory_menu = false;
8629 self.state.show_equip_menu = false;
8630 }
8631 }
8632
8633 pub fn toggle_equip_menu(&mut self) {
8634 self.state.show_equip_menu = !self.state.show_equip_menu;
8635 if self.state.show_equip_menu {
8636 self.state.show_stats = false;
8637 self.state.show_craft_menu = false;
8638 self.state.show_shop_menu = false;
8639 self.state.shop_catalog = None;
8640 self.state.show_inventory_menu = false;
8641 self.state.show_loadout_menu = false;
8642 }
8643 }
8644
8645 pub fn cycle_character_sheet_tab(&mut self) {
8646 if self.state.show_stats {
8647 self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
8648 }
8649 }
8650
8651 pub fn set_ledger_period_digit(&mut self, c: char) {
8652 if self.state.show_stats {
8653 if let Some(p) = LedgerPeriod::from_digit(c) {
8654 self.state.ledger_period = p;
8655 self.state.character_sheet_tab = CharacterSheetTab::Ledger;
8656 }
8657 }
8658 }
8659
8660 pub fn cycle_ledger_period(&mut self) {
8661 if self.state.show_stats && self.state.character_sheet_tab == CharacterSheetTab::Ledger {
8662 self.state.ledger_period = self.state.ledger_period.cycle();
8663 }
8664 }
8665
8666 pub fn open_inventory_menu(&mut self) {
8667 self.state.show_inventory_menu = true;
8668 self.state.show_craft_menu = false;
8669 self.state.show_shop_menu = false;
8670 self.state.shop_catalog = None;
8671 self.state.show_stats = false;
8672 self.state.show_move_picker = false;
8673 self.state.move_picker = None;
8674 self.state.show_destroy_picker = false;
8675 self.state.destroy_confirm_pending = false;
8676 self.state.destroy_picker = None;
8677 self.state.show_rename_prompt = false;
8678 self.state.rename_plot_id = None;
8679 self.state.rename_buffer.clear();
8680 self.state.inventory_filter_focused = false;
8681 self.state.clamp_inventory_indices();
8682 }
8683
8684 pub fn close_inventory_menu(&mut self) {
8685 self.state.show_inventory_menu = false;
8686 self.state.show_move_picker = false;
8687 self.state.move_picker = None;
8688 self.close_grant_picker();
8689 self.state.show_destroy_picker = false;
8690 self.state.destroy_confirm_pending = false;
8691 self.state.destroy_picker = None;
8692 self.state.show_rename_prompt = false;
8693 self.state.rename_plot_id = None;
8694 self.state.rename_buffer.clear();
8695 self.state.inventory_filter_focused = false;
8696 }
8697
8698 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
8699 let Some(row) = self.state.inventory_selected_row() else {
8700 anyhow::bail!("inventory empty");
8701 };
8702 if GameState::is_property_deed_template(&row.stack.template_id) {
8703 let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
8704 anyhow::bail!("deed has no plot id");
8705 };
8706 let label = self
8707 .state
8708 .property_plots
8709 .iter()
8710 .find(|p| p.plot_id == plot_id)
8711 .map(|p| {
8712 if p.label.trim().is_empty() {
8713 p.plot_code.clone()
8714 } else {
8715 p.label.clone()
8716 }
8717 })
8718 .unwrap_or_else(|| {
8719 row.stack
8720 .display_name
8721 .clone()
8722 .unwrap_or_else(|| "plot".into())
8723 });
8724 self.state.rename_buffer = label;
8725 self.state.rename_plot_id = Some(plot_id);
8726 self.state.highlighted_plot_id = Some(plot_id);
8727 self.state.show_rename_prompt = true;
8728 self.state.show_worker_rename = false;
8729 self.state.show_move_picker = false;
8730 self.state.show_destroy_picker = false;
8731 self.state.destroy_confirm_pending = false;
8732 return Ok(());
8733 }
8734 if !self.state.row_is_renameable_container(&row) {
8735 anyhow::bail!("only storage containers or deeds can be renamed");
8736 }
8737 let current = row
8738 .stack
8739 .display_name
8740 .clone()
8741 .unwrap_or_else(|| row.stack.template_id.clone());
8742 self.state.rename_buffer = current;
8743 self.state.rename_plot_id = None;
8744 self.state.show_rename_prompt = true;
8745 self.state.show_worker_rename = false;
8746 self.state.show_move_picker = false;
8747 self.state.show_destroy_picker = false;
8748 self.state.destroy_confirm_pending = false;
8749 Ok(())
8750 }
8751
8752 pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
8754 let Some(plot) = self.state.my_plot_under_player().cloned() else {
8755 anyhow::bail!("stand on your plot to rename it");
8756 };
8757 let label = if plot.label.trim().is_empty() {
8758 plot.plot_code.clone()
8759 } else {
8760 plot.label.clone()
8761 };
8762 self.state.rename_buffer = label;
8763 self.state.rename_plot_id = Some(plot.plot_id);
8764 self.state.highlighted_plot_id = Some(plot.plot_id);
8765 self.state.show_rename_prompt = true;
8766 self.state.show_worker_rename = false;
8767 Ok(())
8768 }
8769
8770 pub fn cancel_rename_prompt(&mut self) {
8771 self.state.show_rename_prompt = false;
8772 self.state.rename_plot_id = None;
8773 self.state.rename_buffer.clear();
8774 }
8775
8776 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
8777 let name = self.state.rename_buffer.trim().to_string();
8778 if name.is_empty() {
8779 anyhow::bail!("name cannot be empty");
8780 }
8781 if let Some(plot_id) = self.state.rename_plot_id {
8782 if name.chars().count() > 48 {
8783 anyhow::bail!("label must be 1–48 characters");
8784 }
8785 self.seq += 1;
8786 self.session
8787 .submit_intent(Intent::RenamePropertyPlot {
8788 entity_id: self.state.entity_id,
8789 plot_id,
8790 label: name,
8791 seq: self.seq,
8792 })
8793 .await?;
8794 self.state.intents_sent += 1;
8795 self.state.show_rename_prompt = false;
8796 self.state.rename_plot_id = None;
8797 self.state.rename_buffer.clear();
8798 return Ok(());
8799 }
8800 if name.chars().count() > 32 {
8801 anyhow::bail!("name must be 1–32 characters");
8802 }
8803 let Some(row) = self.state.inventory_selected_row() else {
8804 anyhow::bail!("inventory empty");
8805 };
8806 let Some(instance_id) = row.stack.item_instance_id else {
8807 anyhow::bail!("item has no instance id");
8808 };
8809 self.seq += 1;
8810 self.session
8811 .submit_intent(Intent::RenameContainer {
8812 entity_id: self.state.entity_id,
8813 item_instance_id: instance_id,
8814 location: row.from.clone(),
8815 name,
8816 seq: self.seq,
8817 })
8818 .await?;
8819 self.state.intents_sent += 1;
8820 self.state.show_rename_prompt = false;
8821 self.state.rename_buffer.clear();
8822 Ok(())
8823 }
8824
8825 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
8826 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8827 anyhow::bail!("no worker selected");
8828 };
8829 self.state.rename_buffer = worker.label.clone();
8830 self.state.show_worker_rename = true;
8831 self.state.show_rename_prompt = false;
8832 Ok(())
8833 }
8834
8835 pub fn cancel_worker_rename(&mut self) {
8836 self.state.show_worker_rename = false;
8837 self.state.rename_buffer.clear();
8838 }
8839
8840 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
8841 let name = self.state.rename_buffer.trim().to_string();
8842 if name.is_empty() {
8843 anyhow::bail!("name cannot be empty");
8844 }
8845 if name.chars().count() > 32 {
8846 anyhow::bail!("name must be 1–32 characters");
8847 }
8848 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8849 anyhow::bail!("no worker selected");
8850 };
8851 let worker_instance_id = worker.instance_id.clone();
8852 self.seq += 1;
8853 self.session
8854 .submit_intent(Intent::RenameHiredWorker {
8855 entity_id: self.state.entity_id,
8856 worker_instance_id: worker_instance_id.clone(),
8857 name: name.clone(),
8858 seq: self.seq,
8859 })
8860 .await?;
8861 self.state.intents_sent += 1;
8862 if let Some(w) = self
8863 .state
8864 .hired_workers
8865 .iter_mut()
8866 .find(|w| w.instance_id == worker_instance_id)
8867 {
8868 w.label = name.clone();
8869 }
8870 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8871 if ed.worker_instance_id == worker_instance_id {
8872 ed.worker_label = name.clone();
8873 }
8874 }
8875 self.state.show_worker_rename = false;
8876 self.state.rename_buffer.clear();
8877 self.state.push_log(format!("Renamed worker to \"{name}\""));
8878 Ok(())
8879 }
8880
8881 pub fn toggle_inventory_menu(&mut self) {
8882 if self.state.show_inventory_menu {
8883 self.close_inventory_menu();
8884 } else {
8885 self.open_inventory_menu();
8886 }
8887 }
8888
8889 pub fn inventory_menu_move(&mut self, delta: i32) {
8891 if self.state.show_grant_picker {
8892 let Some(picker) = self.state.grant_picker.as_ref() else {
8893 return;
8894 };
8895 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8896 let filter = picker.filter.clone();
8897 let n = labels.len();
8898 if n == 0 {
8899 return;
8900 }
8901 self.state.grant_picker_index =
8902 step_filtered_index(self.state.grant_picker_index, delta, n, |i| {
8903 list_label_matches(&labels[i], &filter)
8904 });
8905 return;
8906 }
8907 if self.state.show_move_picker {
8908 let Some(picker) = self.state.move_picker.as_ref() else {
8909 return;
8910 };
8911 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8912 let filter = picker.filter.clone();
8913 let n = labels.len();
8914 if n == 0 {
8915 return;
8916 }
8917 self.state.move_picker_index =
8918 step_filtered_index(self.state.move_picker_index, delta, n, |i| {
8919 list_label_matches(&labels[i], &filter)
8920 });
8921 self.state.clamp_move_picker_quantity();
8922 return;
8923 }
8924 let n = self.state.inventory_selectable_rows().len();
8925 if n == 0 {
8926 return;
8927 }
8928 let idx = self.state.inventory_menu_index as i32;
8929 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8930 }
8931
8932 pub fn inventory_menu_page(&mut self, pages: i32) {
8934 if self.state.show_grant_picker {
8935 let Some(picker) = self.state.grant_picker.as_ref() else {
8936 return;
8937 };
8938 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8939 let filter = picker.filter.clone();
8940 let n = labels.len();
8941 self.state.grant_picker_index =
8942 page_filtered_index(self.state.grant_picker_index, pages, n, |i| {
8943 list_label_matches(&labels[i], &filter)
8944 });
8945 return;
8946 }
8947 if self.state.show_move_picker {
8948 let Some(picker) = self.state.move_picker.as_ref() else {
8949 return;
8950 };
8951 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8952 let filter = picker.filter.clone();
8953 let n = labels.len();
8954 self.state.move_picker_index =
8955 page_filtered_index(self.state.move_picker_index, pages, n, |i| {
8956 list_label_matches(&labels[i], &filter)
8957 });
8958 self.state.clamp_move_picker_quantity();
8959 return;
8960 }
8961 let n = self.state.inventory_selectable_rows().len();
8962 self.state.inventory_menu_index =
8963 page_list_index(self.state.inventory_menu_index, pages, n);
8964 }
8965
8966 pub fn cycle_inventory_tab(&mut self, forward: bool) {
8967 if self.state.show_move_picker
8968 || self.state.show_grant_picker
8969 || self.state.show_destroy_picker
8970 || self.state.show_rename_prompt
8971 || self.state.inventory_filter_focused
8972 {
8973 return;
8974 }
8975 self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
8976 self.state.inventory_menu_index = 0;
8977 self.state.clamp_inventory_indices();
8978 }
8979
8980 pub fn focus_inventory_filter(&mut self) {
8981 if self.state.show_grant_picker {
8982 if let Some(p) = self.state.grant_picker.as_mut() {
8983 p.filter_focused = true;
8984 }
8985 return;
8986 }
8987 if self.state.show_move_picker {
8988 if let Some(p) = self.state.move_picker.as_mut() {
8989 p.filter_focused = true;
8990 }
8991 return;
8992 }
8993 self.state.inventory_filter_focused = true;
8994 }
8995
8996 pub fn set_inventory_filter(&mut self, filter: String) {
8997 self.state.inventory_filter = filter;
8998 self.state.inventory_menu_index = 0;
8999 self.state.clamp_inventory_indices();
9000 }
9001
9002 pub fn append_inventory_filter_char(&mut self, ch: char) {
9003 if !is_list_filter_char(ch) {
9004 return;
9005 }
9006 if self.state.show_grant_picker {
9007 if let Some(p) = self.state.grant_picker.as_mut() {
9008 if p.filter_focused {
9009 p.filter.push(ch);
9010 self.state.grant_picker_index = 0;
9011 }
9012 }
9013 return;
9014 }
9015 if self.state.show_move_picker {
9016 if let Some(p) = self.state.move_picker.as_mut() {
9017 if p.filter_focused {
9018 p.filter.push(ch);
9019 self.state.move_picker_index = 0;
9020 self.state.clamp_move_picker_quantity();
9021 }
9022 }
9023 return;
9024 }
9025 if !self.state.inventory_filter_focused {
9026 return;
9027 }
9028 self.state.inventory_filter.push(ch);
9029 self.state.inventory_menu_index = 0;
9030 self.state.clamp_inventory_indices();
9031 }
9032
9033 pub fn inventory_filter_backspace(&mut self) {
9034 if self.state.show_grant_picker {
9035 if let Some(p) = self.state.grant_picker.as_mut() {
9036 if p.filter_focused {
9037 p.filter.pop();
9038 self.state.grant_picker_index = 0;
9039 }
9040 }
9041 return;
9042 }
9043 if self.state.show_move_picker {
9044 if let Some(p) = self.state.move_picker.as_mut() {
9045 if p.filter_focused {
9046 p.filter.pop();
9047 self.state.move_picker_index = 0;
9048 self.state.clamp_move_picker_quantity();
9049 }
9050 }
9051 return;
9052 }
9053 if !self.state.inventory_filter_focused {
9054 return;
9055 }
9056 self.state.inventory_filter.pop();
9057 self.state.inventory_menu_index = 0;
9058 self.state.clamp_inventory_indices();
9059 }
9060
9061 pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
9063 if self.state.show_grant_picker {
9064 if let Some(p) = self.state.grant_picker.as_mut() {
9065 if p.filter_focused {
9066 if !p.filter.is_empty() {
9067 p.filter.clear();
9068 self.state.grant_picker_index = 0;
9069 } else {
9070 p.filter_focused = false;
9071 }
9072 return true;
9073 }
9074 if !p.filter.is_empty() {
9075 p.filter.clear();
9076 self.state.grant_picker_index = 0;
9077 return true;
9078 }
9079 }
9080 return false;
9081 }
9082 if self.state.show_move_picker {
9083 if let Some(p) = self.state.move_picker.as_mut() {
9084 if p.filter_focused {
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 } else {
9090 p.filter_focused = false;
9091 }
9092 return true;
9093 }
9094 if !p.filter.is_empty() {
9095 p.filter.clear();
9096 self.state.move_picker_index = 0;
9097 self.state.clamp_move_picker_quantity();
9098 return true;
9099 }
9100 }
9101 return false;
9102 }
9103 if self.state.inventory_filter_focused {
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 } else {
9109 self.state.inventory_filter_focused = false;
9110 }
9111 return true;
9112 }
9113 if !self.state.inventory_filter.is_empty() {
9114 self.state.inventory_filter.clear();
9115 self.state.inventory_menu_index = 0;
9116 self.state.clamp_inventory_indices();
9117 return true;
9118 }
9119 false
9120 }
9121
9122 pub fn craft_menu_page(&mut self, pages: i32) {
9123 let n = self.state.craft_filtered_indices().len();
9124 self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
9125 self.state.clamp_craft_batch_quantity();
9126 }
9127
9128 pub fn shop_menu_page(&mut self, pages: i32) {
9129 let n = self.state.shop_list_len();
9130 self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
9131 self.state.clamp_shop_quantity();
9132 }
9133
9134 pub fn workers_menu_page(&mut self, pages: i32) {
9135 let n = self.state.hired_workers.len();
9136 self.state.workers_menu_index = page_list_index(self.state.workers_menu_index, pages, n);
9137 }
9138
9139 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
9144 if self.state.show_destroy_picker {
9145 if self.state.destroy_confirm_pending {
9146 return self.confirm_destroy_item().await;
9147 }
9148 return self.request_destroy_confirm();
9149 }
9150 if self.state.show_grant_picker {
9151 return self.confirm_grant_picker().await;
9152 }
9153 if self.state.show_move_picker {
9154 return self.confirm_move_picker().await;
9155 }
9156 let Some(row) = self.state.inventory_selected_row() else {
9157 anyhow::bail!("inventory empty");
9158 };
9159 if row.is_equip_shell {
9160 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
9161 anyhow::bail!("not a worn item");
9162 };
9163 return self.equip_worn(slot, None).await;
9164 }
9165 if row.is_chest_shell {
9166 return self.open_chest_pickup_picker();
9167 }
9168 let template_id = row.stack.template_id.clone();
9169 let instance_id = row.stack.item_instance_id;
9170 let category = self.state.inventory_item_category(&template_id);
9171 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9172
9173 if category == Some("weapon") {
9174 return self.equip_mainhand(Some(template_id)).await;
9175 }
9176 if category == Some("lodging") && on_person {
9177 if let Some(inst) = instance_id {
9178 return self.place_container(inst).await;
9179 }
9180 }
9181 if on_person {
9183 if let Some(inst) = instance_id {
9184 if row.stack.world_placeable == Some(true) {
9185 return self.place_container(inst).await;
9186 }
9187 }
9188 }
9189 if (category == Some("container") || category == Some("armor")) && on_person {
9190 if let Some(inst) = instance_id {
9191 let world_placeable =
9192 row.stack.world_placeable == Some(true) || template_id.contains("chest");
9193 if world_placeable {
9194 return self.place_container(inst).await;
9195 }
9196 if let Some(slot) = guess_body_slot(&template_id) {
9200 return self.equip_worn(slot, Some(inst)).await;
9201 }
9202 }
9203 }
9204 self.open_move_picker()
9208 }
9209
9210 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
9212 let Some(row) = self.state.inventory_selected_row() else {
9213 anyhow::bail!("inventory empty");
9214 };
9215 if row.from != flatland_protocol::InventoryLocation::Root {
9216 anyhow::bail!("select a consumable on your person");
9217 }
9218 if GameState::stack_is_item_grant(&row.stack) {
9219 return self.open_grant_target_picker();
9220 }
9221 if GameState::is_property_deed_template(&row.stack.template_id) {
9222 return self.open_move_picker();
9223 }
9224 let category = self.state.inventory_item_category(&row.stack.template_id);
9225 if category != Some("consumable") && !GameState::stack_is_serving(&row.stack) {
9226 anyhow::bail!("selected item is not usable");
9227 }
9228 self.use_item(&row.stack.template_id).await
9229 }
9230
9231 pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
9233 let Some(row) = self.state.inventory_selected_row() else {
9234 anyhow::bail!("inventory empty");
9235 };
9236 if row.from != flatland_protocol::InventoryLocation::Root {
9237 anyhow::bail!("select a grant item on your person");
9238 }
9239 if !GameState::stack_is_item_grant(&row.stack) {
9240 anyhow::bail!("selected item does not grant onto gear");
9241 }
9242 let Some(grant_instance_id) = row.stack.item_instance_id else {
9243 anyhow::bail!("grant has no instance id");
9244 };
9245 let effect_id = GameState::grant_effect_id(&row.stack)
9246 .unwrap_or("?")
9247 .to_string();
9248 let mode = GameState::grant_mode(&row.stack).to_string();
9249 let options = self.state.grant_target_options(&row.stack);
9250 if options.is_empty() {
9251 anyhow::bail!("no valid gear to apply {effect_id} to");
9252 }
9253 let grant_label = row
9254 .stack
9255 .display_name
9256 .clone()
9257 .unwrap_or_else(|| row.stack.template_id.clone());
9258 self.state.show_grant_picker = true;
9259 self.state.grant_picker_index = 0;
9260 self.state.grant_picker = Some(GrantTargetPicker {
9261 grant_instance_id,
9262 grant_label,
9263 effect_id,
9264 mode,
9265 options,
9266 filter: String::new(),
9267 filter_focused: false,
9268 });
9269 Ok(())
9270 }
9271
9272 pub fn close_grant_picker(&mut self) {
9273 self.state.show_grant_picker = false;
9274 self.state.grant_picker = None;
9275 self.state.grant_picker_index = 0;
9276 }
9277
9278 pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
9279 let Some(picker) = self.state.grant_picker.clone() else {
9280 self.close_grant_picker();
9281 return Ok(());
9282 };
9283 let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
9284 self.close_grant_picker();
9285 return Ok(());
9286 };
9287 self.close_grant_picker();
9288 self.use_grant(picker.grant_instance_id, opt.target_instance_id)
9289 .await?;
9290 self.state
9291 .push_log(format!("Applying {} onto {}…", picker.effect_id, opt.label));
9292 Ok(())
9293 }
9294
9295 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
9299 let Some(row) = self.state.inventory_selected_row() else {
9300 anyhow::bail!("inventory empty");
9301 };
9302 if row.is_equip_shell {
9303 anyhow::bail!("this is a worn bag — press Enter to unequip it");
9304 }
9305 if row.is_chest_shell {
9306 return self.open_chest_pickup_picker();
9307 }
9308 let Some(instance_id) = row.stack.item_instance_id else {
9309 anyhow::bail!("item has no instance id");
9310 };
9311 let mut options = self.state.move_destinations_for(
9312 &row.from,
9313 row.from_parent_instance_id,
9314 row.stack.item_instance_id,
9315 &row.stack.template_id,
9316 );
9317 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9318 let category = self.state.inventory_item_category(&row.stack.template_id);
9319 if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
9320 if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
9321 options.insert(
9322 0,
9323 MoveOption {
9324 label: "Sell plot to crown…".into(),
9325 kind: MoveOptionKind::SellPlotToCrown { plot_id },
9326 },
9327 );
9328 }
9329 }
9330 if on_person && category == Some("consumable") {
9331 if GameState::stack_is_item_grant(&row.stack) {
9332 options.insert(
9333 0,
9334 MoveOption {
9335 label: "Apply onto gear…".into(),
9336 kind: MoveOptionKind::GrantApply,
9337 },
9338 );
9339 } else {
9340 let study = GameState::stack_is_blueprint_scroll(&row.stack);
9341 options.insert(
9342 0,
9343 MoveOption {
9344 label: if study {
9345 "Study".into()
9346 } else {
9347 "Use (eat / drink)".into()
9348 },
9349 kind: MoveOptionKind::Use,
9350 },
9351 );
9352 }
9353 } else if on_person && GameState::stack_is_serving(&row.stack) {
9354 let label = if GameState::stack_is_food_serving(&row.stack) {
9355 "Use (eat)"
9356 } else {
9357 "Use (fill / drink)"
9358 };
9359 options.insert(
9360 0,
9361 MoveOption {
9362 label: label.into(),
9363 kind: MoveOptionKind::Use,
9364 },
9365 );
9366 }
9367 let item_label = row
9368 .stack
9369 .display_name
9370 .clone()
9371 .unwrap_or_else(|| row.stack.template_id.clone());
9372 let initial_qty = if row.stack.quantity > 1 {
9375 1
9376 } else {
9377 row.stack.quantity
9378 };
9379 self.state.move_picker = Some(MovePicker {
9380 item_instance_id: instance_id,
9381 from: row.from,
9382 item_label,
9383 template_id: row.stack.template_id.clone(),
9384 stack_quantity: row.stack.quantity,
9385 quantity: initial_qty.max(1),
9386 options,
9387 filter: String::new(),
9388 filter_focused: false,
9389 });
9390 self.state.move_picker_index = 0;
9391 self.state.show_move_picker = true;
9392 self.state.show_destroy_picker = false;
9393 self.state.destroy_confirm_pending = false;
9394 self.state.destroy_picker = None;
9395 self.state.clamp_move_picker_quantity();
9396 Ok(())
9397 }
9398
9399 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
9401 let Some(row) = self.state.inventory_selected_row() else {
9402 anyhow::bail!("inventory empty");
9403 };
9404 if !row.is_chest_shell {
9405 anyhow::bail!("not a placed chest");
9406 }
9407 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
9408 anyhow::bail!("not a placed chest");
9409 };
9410 let Some(instance_id) = row.stack.item_instance_id else {
9411 anyhow::bail!("chest has no instance id");
9412 };
9413 let chest = self
9414 .state
9415 .placed_containers
9416 .iter()
9417 .find(|c| c.id == *container_id)
9418 .cloned()
9419 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9420 let (px, py) = self.state.player_position();
9421 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9422 anyhow::bail!("too far from {}", chest.display_name);
9423 }
9424 if chest.locked && !chest.accessible {
9425 anyhow::bail!(
9426 "need the matching key for {} before picking it up",
9427 chest.display_name
9428 );
9429 }
9430 let options = self.state.chest_pickup_destinations(container_id);
9431 let item_label = row
9432 .stack
9433 .display_name
9434 .clone()
9435 .unwrap_or_else(|| row.stack.template_id.clone());
9436 self.state.move_picker = Some(MovePicker {
9437 item_instance_id: instance_id,
9438 from: row.from.clone(),
9439 item_label,
9440 template_id: row.stack.template_id.clone(),
9441 stack_quantity: 1,
9442 quantity: 1,
9443 options,
9444 filter: String::new(),
9445 filter_focused: false,
9446 });
9447 self.state.move_picker_index = 0;
9448 self.state.show_move_picker = true;
9449 self.state.show_destroy_picker = false;
9450 self.state.destroy_confirm_pending = false;
9451 self.state.destroy_picker = None;
9452 Ok(())
9453 }
9454
9455 pub fn close_move_picker(&mut self) {
9456 self.state.show_move_picker = false;
9457 self.state.move_picker = None;
9458 }
9459
9460 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
9461 self.state.move_picker_adjust_quantity(delta);
9462 }
9463
9464 pub fn move_picker_set_quantity_max(&mut self) {
9465 self.state.move_picker_set_quantity_max();
9466 }
9467
9468 pub fn move_picker_set_quantity_min(&mut self) {
9469 self.state.move_picker_set_quantity_min();
9470 }
9471
9472 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
9473 self.state.destroy_picker_adjust_quantity(delta);
9474 }
9475
9476 pub fn destroy_picker_set_quantity_max(&mut self) {
9477 self.state.destroy_picker_set_quantity_max();
9478 }
9479
9480 pub fn destroy_picker_set_quantity_min(&mut self) {
9481 self.state.destroy_picker_set_quantity_min();
9482 }
9483
9484 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
9485 let Some(picker) = self.state.move_picker.clone() else {
9486 self.close_move_picker();
9487 return Ok(());
9488 };
9489 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
9490 self.close_move_picker();
9491 return Ok(());
9492 };
9493 match option.kind {
9494 MoveOptionKind::Cancel => {
9495 self.close_move_picker();
9496 }
9497 MoveOptionKind::Use => {
9498 self.close_move_picker();
9499 self.use_item(&picker.template_id).await?;
9500 }
9501 MoveOptionKind::GrantApply => {
9502 self.close_move_picker();
9503 self.open_grant_target_picker()?;
9504 }
9505 MoveOptionKind::SellPlotToCrown { plot_id } => {
9506 self.close_move_picker();
9507 self.confirm_sell_plot_to_crown(plot_id).await?;
9508 }
9509 MoveOptionKind::RelocatePlaced { container_id } => {
9510 self.close_move_picker();
9511 self.state.show_inventory_menu = false;
9512 self.begin_relocate_container(&container_id)?;
9513 }
9514 MoveOptionKind::Drop => {
9515 self.close_move_picker();
9516 if self
9517 .state
9518 .hand_equipped_instance_ids()
9519 .contains(&picker.item_instance_id)
9520 {
9521 anyhow::bail!("unequip that item first");
9522 }
9523 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
9524 if self.state.deed_bound(&stack) {
9525 anyhow::bail!(
9526 "cannot drop a property deed — store it or trade it to another player"
9527 );
9528 }
9529 if self.state.key_drop_blocked(&stack) {
9530 anyhow::bail!("cannot drop the key while its chest is locked");
9531 }
9532 }
9533 self.drop_item(picker.item_instance_id, picker.from).await?;
9534 self.state
9535 .push_log(format!("Dropped {}", picker.item_label));
9536 }
9537 MoveOptionKind::PickupPlaced {
9538 container_id,
9539 nest_location,
9540 nest_parent_instance_id,
9541 } => {
9542 self.close_move_picker();
9543 self.pickup_container(container_id.clone()).await?;
9544 let nest_into_bag = nest_parent_instance_id.is_some()
9545 || !matches!(nest_location, flatland_protocol::InventoryLocation::Root);
9546 if nest_into_bag {
9547 self.move_item(
9548 picker.item_instance_id,
9549 flatland_protocol::InventoryLocation::Root,
9550 nest_location,
9551 nest_parent_instance_id,
9552 None,
9553 )
9554 .await?;
9555 self.state
9556 .push_log(format!("Picked up {} into bag", picker.item_label));
9557 } else {
9558 self.state
9559 .push_log(format!("Picked up {}", picker.item_label));
9560 }
9561 }
9562 MoveOptionKind::Move {
9563 location,
9564 parent_instance_id,
9565 } => {
9566 self.close_move_picker();
9567 let qty = if picker.quantity >= picker.stack_quantity {
9568 None
9569 } else {
9570 Some(picker.quantity)
9571 };
9572 self.move_item(
9573 picker.item_instance_id,
9574 picker.from,
9575 location,
9576 parent_instance_id,
9577 qty,
9578 )
9579 .await?;
9580 let moved = qty.unwrap_or(picker.stack_quantity);
9581 if moved >= picker.stack_quantity {
9582 self.state.push_log(format!("Moved {}", picker.item_label));
9583 } else {
9584 self.state.push_log(format!(
9585 "Moved {} ×{} of {}",
9586 picker.item_label, moved, picker.stack_quantity
9587 ));
9588 }
9589 }
9590 }
9591 Ok(())
9592 }
9593
9594 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
9598 let Some(row) = self.state.inventory_selected_row() else {
9599 anyhow::bail!("inventory empty");
9600 };
9601 if row.is_equip_shell {
9602 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
9603 }
9604 if row.is_chest_shell {
9605 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
9606 }
9607 let Some(inst) = row.stack.item_instance_id else {
9608 anyhow::bail!("item has no instance id");
9609 };
9610 if self.state.hand_equipped_instance_ids().contains(&inst) {
9611 anyhow::bail!("unequip that item first");
9612 }
9613 if self.state.deed_bound(&row.stack) {
9614 anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
9615 }
9616 if self.state.key_drop_blocked(&row.stack) {
9617 anyhow::bail!("cannot drop the key while its chest is locked");
9618 }
9619 let label = row
9620 .stack
9621 .display_name
9622 .clone()
9623 .unwrap_or_else(|| row.stack.template_id.clone());
9624 let placeable = row.stack.world_placeable == Some(true)
9625 || row.from == flatland_protocol::InventoryLocation::Root
9626 && matches!(
9627 self.state.inventory_item_category(&row.stack.template_id).as_deref(),
9628 Some("lodging")
9629 );
9630 if placeable && row.from == flatland_protocol::InventoryLocation::Root {
9631 self.place_container(inst).await?;
9632 self.state.push_log(format!("Placed {label}"));
9633 return Ok(());
9634 }
9635 self.drop_item(inst, row.from).await?;
9636 self.state.push_log(format!("Dropped {label}"));
9637 Ok(())
9638 }
9639
9640 pub async fn drop_item(
9641 &mut self,
9642 item_instance_id: uuid::Uuid,
9643 from: flatland_protocol::InventoryLocation,
9644 ) -> anyhow::Result<()> {
9645 self.seq += 1;
9646 self.session
9647 .submit_intent(Intent::DropItem {
9648 entity_id: self.state.entity_id,
9649 item_instance_id,
9650 from,
9651 seq: self.seq,
9652 })
9653 .await?;
9654 self.state.intents_sent += 1;
9655 Ok(())
9656 }
9657
9658 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
9660 let Some(row) = self.state.inventory_selected_row() else {
9661 anyhow::bail!("inventory empty");
9662 };
9663 if row.is_equip_shell {
9664 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
9665 }
9666 if row.is_chest_shell {
9667 anyhow::bail!("can't destroy a placed chest from the inventory list");
9668 }
9669 let Some(instance_id) = row.stack.item_instance_id else {
9670 anyhow::bail!("item has no instance id");
9671 };
9672 if self
9673 .state
9674 .hand_equipped_instance_ids()
9675 .contains(&instance_id)
9676 {
9677 anyhow::bail!("unequip that item first");
9678 }
9679 if self.state.deed_bound(&row.stack) {
9680 anyhow::bail!(
9681 "cannot destroy a property deed — store it or trade it to another player"
9682 );
9683 }
9684 if self.state.key_drop_blocked(&row.stack) {
9685 anyhow::bail!("cannot destroy the key while its chest is locked");
9686 }
9687 let item_label = row
9688 .stack
9689 .display_name
9690 .clone()
9691 .unwrap_or_else(|| row.stack.template_id.clone());
9692 self.state.destroy_picker = Some(DestroyPicker {
9693 item_instance_id: instance_id,
9694 from: row.from,
9695 item_label,
9696 stack_quantity: row.stack.quantity,
9697 quantity: row.stack.quantity,
9698 });
9699 self.state.destroy_confirm_pending = false;
9700 self.state.show_destroy_picker = true;
9701 self.state.show_move_picker = false;
9702 self.state.move_picker = None;
9703 Ok(())
9704 }
9705
9706 pub fn close_destroy_picker(&mut self) {
9707 self.state.show_destroy_picker = false;
9708 self.state.destroy_confirm_pending = false;
9709 self.state.destroy_picker = None;
9710 }
9711
9712 pub fn cancel_destroy_confirm(&mut self) {
9713 self.state.destroy_confirm_pending = false;
9714 }
9715
9716 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
9717 if self.state.destroy_picker.is_none() {
9718 self.close_destroy_picker();
9719 return Ok(());
9720 }
9721 self.state.destroy_confirm_pending = true;
9722 Ok(())
9723 }
9724
9725 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
9726 let Some(picker) = self.state.destroy_picker.clone() else {
9727 self.close_destroy_picker();
9728 return Ok(());
9729 };
9730 let qty = if picker.quantity >= picker.stack_quantity {
9731 None
9732 } else {
9733 Some(picker.quantity)
9734 };
9735 self.destroy_item(picker.item_instance_id, picker.from, qty)
9736 .await?;
9737 let destroyed = qty.unwrap_or(picker.stack_quantity);
9738 if destroyed >= picker.stack_quantity {
9739 self.state
9740 .push_log(format!("Destroyed {}", picker.item_label));
9741 } else {
9742 self.state.push_log(format!(
9743 "Destroyed {} ×{} of {}",
9744 picker.item_label, destroyed, picker.stack_quantity
9745 ));
9746 }
9747 self.close_destroy_picker();
9748 Ok(())
9749 }
9750
9751 pub async fn destroy_item(
9752 &mut self,
9753 item_instance_id: uuid::Uuid,
9754 from: flatland_protocol::InventoryLocation,
9755 quantity: Option<u32>,
9756 ) -> anyhow::Result<()> {
9757 self.seq += 1;
9758 self.session
9759 .submit_intent(Intent::DestroyItem {
9760 entity_id: self.state.entity_id,
9761 item_instance_id,
9762 from,
9763 quantity,
9764 seq: self.seq,
9765 })
9766 .await?;
9767 self.state.intents_sent += 1;
9768 Ok(())
9769 }
9770
9771 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
9773 if let Some(row) = self.state.inventory_selected_row() {
9774 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
9775 return self.toggle_placed_chest_lock(container_id).await;
9776 }
9777 }
9778 self.toggle_nearby_chest_lock().await
9779 }
9780
9781 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
9782 let chest = self
9783 .state
9784 .placed_containers
9785 .iter()
9786 .find(|c| c.id == container_id)
9787 .cloned()
9788 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9789 let (px, py) = self.state.player_position();
9790 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9791 anyhow::bail!("too far from {}", chest.display_name);
9792 }
9793 if !chest.accessible && chest.locked {
9794 anyhow::bail!(
9795 "need the matching key for {} (each crafted chest has its own key)",
9796 chest.display_name
9797 );
9798 }
9799 let lock = !chest.locked;
9800 self.set_container_locked(
9801 flatland_protocol::InventoryLocation::Placed {
9802 container_id: chest.id.clone(),
9803 },
9804 lock,
9805 )
9806 .await?;
9807 self.state.push_log(if lock {
9808 format!("Locked {}", chest.display_name)
9809 } else {
9810 format!("Unlocked {}", chest.display_name)
9811 });
9812 Ok(())
9813 }
9814
9815 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
9817 let chest = self
9818 .state
9819 .nearest_placed_container(CONTAINER_RANGE_M)
9820 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
9821 self.toggle_placed_chest_lock(&chest.id).await
9822 }
9823
9824 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
9825 self.equip_mainhand(None).await
9826 }
9827
9828 pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
9829 if !self.state.is_alive() {
9830 anyhow::bail!("you are dead");
9831 }
9832 self.seq += 1;
9833 self.session
9834 .submit_intent(Intent::EquipOffhand {
9835 entity_id: self.state.entity_id,
9836 template_id,
9837 instance_id: None,
9838 seq: self.seq,
9839 })
9840 .await?;
9841 self.state.intents_sent += 1;
9842 Ok(())
9843 }
9844
9845 pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
9846 self.equip_offhand(None).await
9847 }
9848
9849 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
9850 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
9851 for slot in slots {
9852 self.equip_worn(slot, None).await?;
9853 }
9854 Ok(())
9855 }
9856
9857 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
9858 let (px, py) = self.state.player_position();
9859 let in_range: Vec<_> = self
9860 .state
9861 .placed_containers
9862 .iter()
9863 .filter(|c| self.state.placed_container_in_current_space(c))
9864 .filter(|c| (c.x - px).hypot(c.y - py) <= 2.0)
9865 .collect();
9866 let nearest_free = in_range
9867 .iter()
9868 .copied()
9869 .filter(|c| !self.state.lodging_is_occupied(&c.id))
9870 .min_by(|a, b| {
9871 let da = (a.x - px).hypot(a.y - py);
9872 let db = (b.x - px).hypot(b.y - py);
9873 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
9874 })
9875 .cloned();
9876 if let Some(chest) = nearest_free {
9877 return self.pickup_container(chest.id).await;
9878 }
9879 if in_range
9880 .iter()
9881 .any(|c| self.state.lodging_is_occupied(&c.id))
9882 {
9883 anyhow::bail!("dismiss or reassign workers before picking up lodging");
9884 }
9885 if in_range.is_empty() {
9886 anyhow::bail!("no chest nearby");
9887 }
9888 anyhow::bail!("too far from chest");
9889 }
9890
9891 pub async fn equip_worn(
9892 &mut self,
9893 slot: BodySlot,
9894 instance_id: Option<uuid::Uuid>,
9895 ) -> anyhow::Result<()> {
9896 self.seq += 1;
9897 self.session
9898 .submit_intent(Intent::EquipWorn {
9899 entity_id: self.state.entity_id,
9900 slot,
9901 instance_id,
9902 seq: self.seq,
9903 })
9904 .await?;
9905 self.state.intents_sent += 1;
9906 Ok(())
9907 }
9908
9909 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
9910 self.seq += 1;
9911 self.session
9912 .submit_intent(Intent::PlaceContainer {
9913 entity_id: self.state.entity_id,
9914 item_instance_id,
9915 seq: self.seq,
9916 })
9917 .await?;
9918 self.state.intents_sent += 1;
9919 Ok(())
9920 }
9921
9922 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
9923 self.seq += 1;
9924 self.session
9925 .submit_intent(Intent::PickupContainer {
9926 entity_id: self.state.entity_id,
9927 container_id,
9928 seq: self.seq,
9929 })
9930 .await?;
9931 self.state.intents_sent += 1;
9932 Ok(())
9933 }
9934
9935 pub async fn move_item(
9936 &mut self,
9937 item_instance_id: uuid::Uuid,
9938 from: flatland_protocol::InventoryLocation,
9939 to: flatland_protocol::InventoryLocation,
9940 to_parent_instance_id: Option<uuid::Uuid>,
9941 quantity: Option<u32>,
9942 ) -> anyhow::Result<()> {
9943 self.seq += 1;
9944 self.session
9945 .submit_intent(Intent::MoveItem {
9946 entity_id: self.state.entity_id,
9947 item_instance_id,
9948 from,
9949 to,
9950 to_parent_instance_id,
9951 quantity,
9952 seq: self.seq,
9953 })
9954 .await?;
9955 self.state.intents_sent += 1;
9956 Ok(())
9957 }
9958
9959 pub async fn set_container_locked(
9960 &mut self,
9961 location: flatland_protocol::InventoryLocation,
9962 locked: bool,
9963 ) -> anyhow::Result<()> {
9964 self.seq += 1;
9965 self.session
9966 .submit_intent(Intent::SetContainerLocked {
9967 entity_id: self.state.entity_id,
9968 location,
9969 locked,
9970 seq: self.seq,
9971 })
9972 .await?;
9973 self.state.intents_sent += 1;
9974 Ok(())
9975 }
9976
9977 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
9978 if !self.state.is_alive() {
9979 anyhow::bail!("you are dead");
9980 }
9981 self.seq += 1;
9982 self.session
9983 .submit_intent(Intent::Use {
9984 entity_id: self.state.entity_id,
9985 template_id: template_id.to_string(),
9986 seq: self.seq,
9987 })
9988 .await?;
9989 self.state.intents_sent += 1;
9990 Ok(())
9991 }
9992
9993 pub async fn use_grant(
9995 &mut self,
9996 grant_instance_id: uuid::Uuid,
9997 target_instance_id: uuid::Uuid,
9998 ) -> anyhow::Result<()> {
9999 if !self.state.is_alive() {
10000 anyhow::bail!("you are dead");
10001 }
10002 self.seq += 1;
10003 self.session
10004 .submit_intent(Intent::UseGrant {
10005 entity_id: self.state.entity_id,
10006 grant_instance_id,
10007 target_instance_id,
10008 seq: self.seq,
10009 })
10010 .await?;
10011 self.state.intents_sent += 1;
10012 Ok(())
10013 }
10014
10015 pub fn open_craft_menu(&mut self) {
10016 self.state.show_craft_menu = true;
10017 self.state.show_shop_menu = false;
10018 self.state.shop_catalog = None;
10019 self.state.show_stats = false;
10020 self.state.show_inventory_menu = false;
10021 self.state.reload_craft_prefs();
10022 self.state.craft_tab = CraftTab::Ready;
10023 self.state.craft_filter.clear();
10024 self.state.craft_filter_focused = false;
10025 self.state.craft_menu_index = 0;
10026 self.state.clamp_craft_menu_index();
10027 self.state.craft_batch_quantity = 1;
10028 self.state.clamp_craft_batch_quantity();
10029 }
10030
10031 pub fn close_craft_menu(&mut self) {
10032 self.state.show_craft_menu = false;
10033 self.state.craft_filter_focused = false;
10034 }
10035
10036 pub fn toggle_keychain_menu(&mut self) {
10037 if self.state.show_keychain_menu {
10038 self.close_keychain_menu();
10039 } else {
10040 self.state.show_keychain_menu = true;
10041 self.state.show_craft_menu = false;
10042 self.state.show_shop_menu = false;
10043 self.state.show_inventory_menu = false;
10044 let n = self.state.keychain_entries().len();
10045 if n == 0 {
10046 self.state.keychain_menu_index = 0;
10047 } else {
10048 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
10049 }
10050 }
10051 }
10052
10053 pub fn close_keychain_menu(&mut self) {
10054 self.state.show_keychain_menu = false;
10055 }
10056
10057 pub fn keychain_menu_move(&mut self, delta: i32) {
10058 let n = self.state.keychain_entries().len();
10059 if n == 0 {
10060 self.state.keychain_menu_index = 0;
10061 return;
10062 }
10063 let idx = self.state.keychain_menu_index as i32 + delta;
10064 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
10065 }
10066
10067 pub fn keychain_menu_page(&mut self, pages: i32) {
10068 let n = self.state.keychain_entries().len();
10069 self.state.keychain_menu_index = page_list_index(self.state.keychain_menu_index, pages, n);
10070 }
10071
10072 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
10073 if !self.state.is_alive() {
10074 anyhow::bail!("you are dead");
10075 }
10076 let entries = self.state.keychain_entries();
10077 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
10078 anyhow::bail!("nothing selected");
10079 };
10080 let Some(instance_id) = entry.stack.item_instance_id else {
10081 anyhow::bail!("key has no instance id");
10082 };
10083 if entry.stowed {
10084 self.move_item(
10085 instance_id,
10086 flatland_protocol::InventoryLocation::Keychain,
10087 flatland_protocol::InventoryLocation::Root,
10088 None,
10089 Some(1),
10090 )
10091 .await
10092 } else {
10093 self.move_item(
10094 instance_id,
10095 flatland_protocol::InventoryLocation::Root,
10096 flatland_protocol::InventoryLocation::Keychain,
10097 None,
10098 Some(1),
10099 )
10100 .await
10101 }
10102 }
10103
10104 pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
10105 let npc_id = self.state.shop_catalog.as_ref().map(|c| c.npc_id.clone());
10106 self.state.show_shop_menu = false;
10107 self.state.shop_catalog = None;
10108 self.state.clear_shop_trade_log();
10109 if let Some(npc_id) = npc_id {
10110 self.seq += 1;
10111 self.session
10112 .submit_intent(Intent::ShopClose {
10113 entity_id: self.state.entity_id,
10114 npc_id,
10115 seq: self.seq,
10116 })
10117 .await?;
10118 self.state.intents_sent += 1;
10119 }
10120 Ok(())
10121 }
10122
10123 pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10124 let Some(panel) = self.state.bank_panel.clone() else {
10125 return Ok(());
10126 };
10127 self.seq += 1;
10128 self.session
10129 .submit_intent(Intent::BankDeposit {
10130 entity_id: self.state.entity_id,
10131 npc_id: panel.npc_id,
10132 amount_copper,
10133 seq: self.seq,
10134 })
10135 .await?;
10136 self.state.intents_sent += 1;
10137 Ok(())
10138 }
10139
10140 pub async fn bank_withdraw(&mut self, amount_copper: u64) -> 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::BankWithdraw {
10147 entity_id: self.state.entity_id,
10148 npc_id: panel.npc_id,
10149 amount_copper,
10150 seq: self.seq,
10151 })
10152 .await?;
10153 self.state.intents_sent += 1;
10154 Ok(())
10155 }
10156
10157 pub async fn bank_transfer(
10158 &mut self,
10159 to_character_id: Option<uuid::Uuid>,
10160 to_name: String,
10161 amount_copper: u64,
10162 ) -> anyhow::Result<()> {
10163 let Some(panel) = self.state.bank_panel.clone() else {
10164 return Ok(());
10165 };
10166 self.seq += 1;
10167 self.session
10168 .submit_intent(Intent::BankTransfer {
10169 entity_id: self.state.entity_id,
10170 npc_id: panel.npc_id,
10171 to_character_id,
10172 to_name,
10173 amount_copper,
10174 seq: self.seq,
10175 })
10176 .await?;
10177 self.state.intents_sent += 1;
10178 Ok(())
10179 }
10180
10181 pub fn bank_menu_move(&mut self, delta: i32) {
10182 let n = self.state.bank_menu_options().len();
10183 if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
10184 return;
10185 }
10186 let idx = self.state.bank_menu_index as i32;
10187 self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10188 }
10189
10190 pub fn storage_menu_move(&mut self, delta: i32) {
10191 let n = self.state.storage_menu_options().len();
10192 if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
10193 return;
10194 }
10195 let idx = self.state.storage_menu_index as i32;
10196 self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10197 }
10198
10199 pub fn storage_pick_move(&mut self, delta: i32) {
10200 let n = match &self.state.storage_ui_mode {
10201 StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
10202 StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
10203 self.state.storage_vault_options().len()
10204 }
10205 StorageUiMode::Menu
10206 | StorageUiMode::StoreAmount { .. }
10207 | StorageUiMode::TakeAmount { .. }
10208 | StorageUiMode::ShipAmount { .. } => 0,
10209 };
10210 if n == 0 {
10211 return;
10212 }
10213 match &mut self.state.storage_ui_mode {
10214 StorageUiMode::StorePick { index }
10215 | StorageUiMode::TakePick { index }
10216 | StorageUiMode::ShipPick { index, .. } => {
10217 *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10218 }
10219 StorageUiMode::Menu
10220 | StorageUiMode::StoreAmount { .. }
10221 | StorageUiMode::TakeAmount { .. }
10222 | StorageUiMode::ShipAmount { .. } => {}
10223 }
10224 }
10225
10226 pub fn storage_ui_back(&mut self) {
10227 self.state.storage_ui_mode = match &self.state.storage_ui_mode {
10228 StorageUiMode::StoreAmount { pick_index, .. } => {
10229 StorageUiMode::StorePick { index: *pick_index }
10230 }
10231 StorageUiMode::TakeAmount { pick_index, .. } => {
10232 StorageUiMode::TakePick { index: *pick_index }
10233 }
10234 StorageUiMode::ShipAmount {
10235 dest_building_id,
10236 dest_label,
10237 pick_index,
10238 ..
10239 } => StorageUiMode::ShipPick {
10240 dest_building_id: dest_building_id.clone(),
10241 dest_label: dest_label.clone(),
10242 index: *pick_index,
10243 },
10244 StorageUiMode::StorePick { .. }
10245 | StorageUiMode::TakePick { .. }
10246 | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
10247 StorageUiMode::Menu => StorageUiMode::Menu,
10248 };
10249 }
10250
10251 pub fn storage_amount_append_char(&mut self, c: char) {
10252 match &mut self.state.storage_ui_mode {
10253 StorageUiMode::StoreAmount { input, .. }
10254 | StorageUiMode::TakeAmount { input, .. }
10255 | StorageUiMode::ShipAmount { input, .. } => {
10256 if c.is_ascii_digit() && input.len() < 8 {
10257 input.push(c);
10258 }
10259 }
10260 _ => {}
10261 }
10262 }
10263
10264 pub fn storage_amount_backspace(&mut self) {
10265 match &mut self.state.storage_ui_mode {
10266 StorageUiMode::StoreAmount { input, .. }
10267 | StorageUiMode::TakeAmount { input, .. }
10268 | StorageUiMode::ShipAmount { input, .. } => {
10269 input.pop();
10270 }
10271 _ => {}
10272 }
10273 }
10274
10275 pub fn storage_ui_typing(&self) -> bool {
10276 matches!(
10277 self.state.storage_ui_mode,
10278 StorageUiMode::StoreAmount { .. }
10279 | StorageUiMode::TakeAmount { .. }
10280 | StorageUiMode::ShipAmount { .. }
10281 )
10282 }
10283
10284 pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
10285 match self.state.storage_ui_mode.clone() {
10286 StorageUiMode::Menu => {
10287 let index = self.state.storage_menu_index;
10288 match index {
10289 0 => {
10290 let opts = self.state.storage_store_options();
10291 if opts.is_empty() {
10292 self.state.push_log("Nothing loose to store.");
10293 return Ok(());
10294 }
10295 self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
10296 }
10297 1 => {
10298 let opts = self.state.storage_vault_options();
10299 if opts.is_empty() {
10300 self.state.push_log("Vault is empty.");
10301 return Ok(());
10302 }
10303 self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
10304 }
10305 n => {
10306 let dest = self
10307 .state
10308 .storage_panel
10309 .as_ref()
10310 .and_then(|p| p.ship_destinations.get(n - 2))
10311 .cloned();
10312 let Some(dest) = dest else {
10313 return Ok(());
10314 };
10315 let opts = self.state.storage_vault_options();
10316 if opts.is_empty() {
10317 self.state.push_log("Vault is empty — nothing to ship.");
10318 return Ok(());
10319 }
10320 self.state.storage_ui_mode = StorageUiMode::ShipPick {
10321 dest_building_id: dest.building_id,
10322 dest_label: dest.label,
10323 index: 0,
10324 };
10325 }
10326 }
10327 }
10328 StorageUiMode::StorePick { index } => {
10329 let opts = self.state.storage_store_options();
10330 let Some(opt) = opts.get(index) else {
10331 self.state.push_log("Nothing loose to store.");
10332 self.state.storage_ui_mode = StorageUiMode::Menu;
10333 return Ok(());
10334 };
10335 self.state.storage_ui_mode = StorageUiMode::StoreAmount {
10336 pick_index: index,
10337 item_instance_id: opt.item_instance_id,
10338 label: opt.label.clone(),
10339 max_qty: opt.quantity.max(1),
10340 input: String::new(),
10341 };
10342 }
10343 StorageUiMode::TakePick { index } => {
10344 let opts = self.state.storage_vault_options();
10345 let Some(opt) = opts.get(index) else {
10346 self.state.push_log("Vault is empty.");
10347 self.state.storage_ui_mode = StorageUiMode::Menu;
10348 return Ok(());
10349 };
10350 self.state.storage_ui_mode = StorageUiMode::TakeAmount {
10351 pick_index: index,
10352 item_instance_id: opt.item_instance_id,
10353 label: opt.label.clone(),
10354 max_qty: opt.quantity.max(1),
10355 input: String::new(),
10356 };
10357 }
10358 StorageUiMode::ShipPick {
10359 dest_building_id,
10360 dest_label,
10361 index,
10362 } => {
10363 let opts = self.state.storage_vault_options();
10364 let Some(opt) = opts.get(index) else {
10365 self.state.push_log("Vault is empty — nothing to ship.");
10366 self.state.storage_ui_mode = StorageUiMode::Menu;
10367 return Ok(());
10368 };
10369 self.state.storage_ui_mode = StorageUiMode::ShipAmount {
10370 dest_building_id,
10371 dest_label,
10372 pick_index: index,
10373 item_instance_id: opt.item_instance_id,
10374 label: opt.label.clone(),
10375 max_qty: opt.quantity.max(1),
10376 input: String::new(),
10377 };
10378 }
10379 StorageUiMode::StoreAmount {
10380 item_instance_id,
10381 max_qty,
10382 input,
10383 ..
10384 } => {
10385 let Some(qty) = parse_storage_quantity(&input) else {
10386 self.state.push_log("Enter a quantity (blank or 0 = all).");
10387 return Ok(());
10388 };
10389 let qty = qty.map(|n| n.min(max_qty).max(1));
10390 self.storage_store(item_instance_id, qty).await?;
10391 self.state.storage_ui_mode = StorageUiMode::Menu;
10392 }
10393 StorageUiMode::TakeAmount {
10394 item_instance_id,
10395 max_qty,
10396 input,
10397 ..
10398 } => {
10399 let Some(qty) = parse_storage_quantity(&input) else {
10400 self.state.push_log("Enter a quantity (blank or 0 = all).");
10401 return Ok(());
10402 };
10403 let qty = qty.map(|n| n.min(max_qty).max(1));
10404 self.storage_take(item_instance_id, qty).await?;
10405 self.state.storage_ui_mode = StorageUiMode::Menu;
10406 }
10407 StorageUiMode::ShipAmount {
10408 dest_building_id,
10409 item_instance_id,
10410 max_qty,
10411 input,
10412 ..
10413 } => {
10414 let Some(qty) = parse_storage_quantity(&input) else {
10415 self.state.push_log("Enter a quantity (blank or 0 = all).");
10416 return Ok(());
10417 };
10418 let qty = qty.map(|n| n.min(max_qty).max(1));
10419 self.storage_ship(dest_building_id, item_instance_id, qty)
10420 .await?;
10421 self.state.storage_ui_mode = StorageUiMode::Menu;
10422 }
10423 }
10424 Ok(())
10425 }
10426
10427 pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
10428 match self.state.bank_ui_mode.clone() {
10429 BankUiMode::Menu => {
10430 let choice = self
10431 .state
10432 .bank_menu_options()
10433 .get(self.state.bank_menu_index)
10434 .copied()
10435 .unwrap_or("Deposit…");
10436 match choice {
10437 "Withdraw…" => {
10438 self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
10439 input: String::new(),
10440 };
10441 }
10442 "Deposit all" => self.bank_deposit(0).await?,
10443 "Withdraw all" => self.bank_withdraw(0).await?,
10444 "Transfer…" => {
10445 self.state.bank_ui_mode = BankUiMode::TransferName {
10446 input: String::new(),
10447 };
10448 }
10449 _ => {
10450 self.state.bank_ui_mode = BankUiMode::DepositAmount {
10451 input: String::new(),
10452 };
10453 }
10454 }
10455 }
10456 BankUiMode::DepositAmount { input } => {
10457 let Some(amount) = parse_bank_copper_amount(&input) else {
10458 self.state
10459 .push_log("Enter a copper amount (blank or 0 = everything on person).");
10460 return Ok(());
10461 };
10462 self.bank_deposit(amount).await?;
10463 self.state.bank_ui_mode = BankUiMode::Menu;
10464 }
10465 BankUiMode::WithdrawAmount { input } => {
10466 let Some(amount) = parse_bank_copper_amount(&input) else {
10467 self.state
10468 .push_log("Enter a copper amount (blank or 0 = full ledger).");
10469 return Ok(());
10470 };
10471 self.bank_withdraw(amount).await?;
10472 self.state.bank_ui_mode = BankUiMode::Menu;
10473 }
10474 BankUiMode::TransferName { input } => {
10475 let name = input.trim().to_string();
10476 if name.is_empty() {
10477 self.state.push_log("Enter the recipient character name.");
10478 return Ok(());
10479 }
10480 self.state.bank_ui_mode = BankUiMode::TransferAmount {
10481 to_name: name,
10482 input: String::new(),
10483 };
10484 }
10485 BankUiMode::TransferAmount { to_name, input } => {
10486 let amount: u64 = match input.trim().parse() {
10487 Ok(v) if v > 0 => v,
10488 _ => {
10489 self.state
10490 .push_log("Enter a positive copper amount to transfer.");
10491 return Ok(());
10492 }
10493 };
10494 self.bank_transfer(None, to_name, amount).await?;
10495 self.state.bank_ui_mode = BankUiMode::Menu;
10496 }
10497 }
10498 Ok(())
10499 }
10500
10501 pub fn bank_transfer_back(&mut self) {
10502 match &self.state.bank_ui_mode {
10503 BankUiMode::TransferAmount { to_name, .. } => {
10504 self.state.bank_ui_mode = BankUiMode::TransferName {
10505 input: to_name.clone(),
10506 };
10507 }
10508 BankUiMode::TransferName { .. }
10509 | BankUiMode::DepositAmount { .. }
10510 | BankUiMode::WithdrawAmount { .. } => {
10511 self.state.bank_ui_mode = BankUiMode::Menu;
10512 }
10513 BankUiMode::Menu => {}
10514 }
10515 }
10516
10517 pub fn bank_transfer_append_char(&mut self, c: char) {
10518 match &mut self.state.bank_ui_mode {
10519 BankUiMode::TransferName { input } => {
10520 if input.len() < 32 && !c.is_control() {
10521 input.push(c);
10522 }
10523 }
10524 BankUiMode::DepositAmount { input }
10525 | BankUiMode::WithdrawAmount { input }
10526 | BankUiMode::TransferAmount { input, .. } => {
10527 if c.is_ascii_digit() && input.len() < 12 {
10528 input.push(c);
10529 }
10530 }
10531 BankUiMode::Menu => {}
10532 }
10533 }
10534
10535 pub fn bank_transfer_backspace(&mut self) {
10536 match &mut self.state.bank_ui_mode {
10537 BankUiMode::TransferName { input }
10538 | BankUiMode::DepositAmount { input }
10539 | BankUiMode::WithdrawAmount { input }
10540 | BankUiMode::TransferAmount { input, .. } => {
10541 input.pop();
10542 }
10543 BankUiMode::Menu => {}
10544 }
10545 }
10546
10547 pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
10548 let npc_id = self.state.bank_panel.as_ref().map(|p| p.npc_id.clone());
10549 self.state.clear_bank_panel();
10550 if let Some(npc_id) = npc_id {
10551 self.seq += 1;
10552 self.session
10553 .submit_intent(Intent::BankClose {
10554 entity_id: self.state.entity_id,
10555 npc_id,
10556 seq: self.seq,
10557 })
10558 .await?;
10559 self.state.intents_sent += 1;
10560 }
10561 Ok(())
10562 }
10563
10564 pub async fn storage_store(
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::StorageStore {
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_take(
10587 &mut self,
10588 item_instance_id: uuid::Uuid,
10589 quantity: Option<u32>,
10590 ) -> anyhow::Result<()> {
10591 let Some(panel) = self.state.storage_panel.clone() else {
10592 return Ok(());
10593 };
10594 self.seq += 1;
10595 self.session
10596 .submit_intent(Intent::StorageTake {
10597 entity_id: self.state.entity_id,
10598 npc_id: panel.npc_id,
10599 item_instance_id,
10600 quantity,
10601 seq: self.seq,
10602 })
10603 .await?;
10604 self.state.intents_sent += 1;
10605 Ok(())
10606 }
10607
10608 pub async fn storage_ship(
10609 &mut self,
10610 dest_building_id: String,
10611 item_instance_id: uuid::Uuid,
10612 quantity: Option<u32>,
10613 ) -> anyhow::Result<()> {
10614 let Some(panel) = self.state.storage_panel.clone() else {
10615 return Ok(());
10616 };
10617 self.seq += 1;
10618 self.session
10619 .submit_intent(Intent::StorageShip {
10620 entity_id: self.state.entity_id,
10621 npc_id: panel.npc_id,
10622 dest_building_id,
10623 item_instance_id,
10624 quantity,
10625 seq: self.seq,
10626 })
10627 .await?;
10628 self.state.intents_sent += 1;
10629 Ok(())
10630 }
10631
10632 pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
10633 let npc_id = self.state.storage_panel.as_ref().map(|p| p.npc_id.clone());
10634 self.state.clear_storage_panel();
10635 if let Some(npc_id) = npc_id {
10636 self.seq += 1;
10637 self.session
10638 .submit_intent(Intent::StorageClose {
10639 entity_id: self.state.entity_id,
10640 npc_id,
10641 seq: self.seq,
10642 })
10643 .await?;
10644 self.state.intents_sent += 1;
10645 }
10646 Ok(())
10647 }
10648
10649 pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
10650 let npc_id = self.state.market_panel.as_ref().map(|p| p.npc_id.clone());
10651 self.state.clear_market_panel();
10652 if let Some(npc_id) = npc_id {
10653 self.seq += 1;
10654 self.session
10655 .submit_intent(Intent::MarketClose {
10656 entity_id: self.state.entity_id,
10657 npc_id,
10658 seq: self.seq,
10659 })
10660 .await?;
10661 self.state.intents_sent += 1;
10662 }
10663 Ok(())
10664 }
10665
10666 pub fn market_move_selection(&mut self, delta: i32) {
10667 let indices = self.state.market_filtered_listing_indices();
10668 let n = indices.len();
10669 if n == 0 {
10670 self.state.market_menu_index = 0;
10671 return;
10672 }
10673 let cur = self.state.market_menu_index as i32;
10674 self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
10675 }
10676
10677 pub fn market_page_selection(&mut self, pages: i32) {
10678 let indices = self.state.market_filtered_listing_indices();
10679 let n = indices.len();
10680 if n == 0 {
10681 self.state.market_menu_index = 0;
10682 return;
10683 }
10684 self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
10685 }
10686
10687 pub fn market_list_page(&mut self, pages: i32) {
10688 match &self.state.market_ui_mode {
10689 MarketUiMode::ListSource { index } => {
10690 let n = self.state.market_list_source_options().len();
10691 if n == 0 {
10692 return;
10693 }
10694 let next = page_list_index(*index, pages, n);
10695 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
10696 }
10697 MarketUiMode::ListPricingMode { index, .. } => {
10698 let next = page_list_index(*index, pages, 2);
10699 if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
10700 {
10701 *index = next;
10702 }
10703 }
10704 MarketUiMode::ListPick { source, index } => {
10705 let opts = self.state.market_list_item_options(source);
10706 let n = opts.len();
10707 if n == 0 {
10708 return;
10709 }
10710 let next = page_list_index(*index, pages, n);
10711 self.state.market_ui_mode = MarketUiMode::ListPick {
10712 source: source.clone(),
10713 index: next,
10714 };
10715 }
10716 _ => {}
10717 }
10718 }
10719
10720 pub fn market_cycle_category(&mut self, delta: i32) {
10721 let groups = self.state.market_available_category_groups();
10722 let mut labels: Vec<Option<&'static str>> = vec![None];
10724 labels.extend(groups.into_iter().map(Some));
10725 let n = labels.len() as i32;
10726 let cur = labels
10727 .iter()
10728 .position(|g| *g == self.state.market_category_filter)
10729 .unwrap_or(0) as i32;
10730 let next = (cur + delta).rem_euclid(n) as usize;
10731 self.state.market_category_filter = labels[next];
10732 self.state.market_menu_index = 0;
10733 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10734 let source = source.clone();
10735 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10736 }
10737 }
10738
10739 pub fn focus_market_filter(&mut self) {
10740 self.state.market_filter_focused = true;
10741 }
10742
10743 pub fn append_market_filter_char(&mut self, ch: char) {
10744 if !self.state.market_filter_focused {
10745 return;
10746 }
10747 if !is_list_filter_char(ch) {
10748 return;
10749 }
10750 if self.state.market_filter.len() < 48 {
10751 self.state.market_filter.push(ch);
10752 self.state.market_menu_index = 0;
10753 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10754 let source = source.clone();
10755 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10756 }
10757 }
10758 }
10759
10760 pub fn market_filter_backspace(&mut self) {
10761 if !self.state.market_filter_focused {
10762 return;
10763 }
10764 self.state.market_filter.pop();
10765 self.state.market_menu_index = 0;
10766 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10767 let source = source.clone();
10768 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10769 }
10770 }
10771
10772 pub fn clear_or_blur_market_filter(&mut self) -> bool {
10774 if self.state.market_filter_focused {
10775 if !self.state.market_filter.is_empty() {
10776 self.state.market_filter.clear();
10777 self.state.market_menu_index = 0;
10778 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10779 let source = source.clone();
10780 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10781 }
10782 return true;
10783 }
10784 self.state.market_filter_focused = false;
10785 return true;
10786 }
10787 if !self.state.market_filter.is_empty() {
10788 self.state.market_filter.clear();
10789 self.state.market_menu_index = 0;
10790 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10791 let source = source.clone();
10792 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10793 }
10794 return true;
10795 }
10796 false
10797 }
10798
10799 pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
10800 if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
10801 return self.market_confirm_buy(listing_id, qty).await;
10802 }
10803 let Some(panel) = self.state.market_panel.clone() else {
10804 return Ok(());
10805 };
10806 let indices = self.state.market_filtered_listing_indices();
10807 let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
10808 return Ok(());
10809 };
10810 let Some(listing) = panel.listings.get(raw_idx) else {
10811 return Ok(());
10812 };
10813 if listing.mine {
10814 self.seq += 1;
10815 self.session
10816 .submit_intent(Intent::MarketDelist {
10817 entity_id: self.state.entity_id,
10818 npc_id: panel.npc_id.clone(),
10819 listing_id: listing.listing_id,
10820 dest: flatland_protocol::GoodsLocation::Person,
10821 seq: self.seq,
10822 })
10823 .await?;
10824 self.state.intents_sent += 1;
10825 return Ok(());
10826 }
10827 if listing.npc_price {
10828 self.state
10829 .push_log("NPC-price listings are bought by merchants only.");
10830 return Ok(());
10831 }
10832 let qty = 1u32.min(listing.quantity).max(1);
10833 let line = listing.unit_price_copper.saturating_mul(qty as u64);
10834 self.state.market_buy_confirm = Some((
10835 listing.listing_id,
10836 qty,
10837 listing.unit_price_copper,
10838 line,
10839 listing.display_name.clone(),
10840 ));
10841 Ok(())
10842 }
10843
10844 pub async fn market_confirm_buy(
10845 &mut self,
10846 listing_id: uuid::Uuid,
10847 quantity: u32,
10848 ) -> anyhow::Result<()> {
10849 let Some(panel) = self.state.market_panel.clone() else {
10850 self.state.market_buy_confirm = None;
10851 return Ok(());
10852 };
10853 self.state.market_buy_confirm = None;
10854 self.seq += 1;
10855 self.session
10856 .submit_intent(Intent::MarketBuy {
10857 entity_id: self.state.entity_id,
10858 npc_id: panel.npc_id,
10859 listing_id,
10860 quantity,
10861 dest: flatland_protocol::GoodsLocation::Person,
10862 seq: self.seq,
10863 })
10864 .await?;
10865 self.state.intents_sent += 1;
10866 Ok(())
10867 }
10868
10869 pub fn market_begin_list(&mut self) {
10871 if self.state.market_panel.is_none() {
10872 return;
10873 }
10874 let sources = self.state.market_list_source_options();
10875 if sources.is_empty() {
10876 self.state.push_log("Nothing to list from.");
10877 return;
10878 }
10879 if sources.len() == 1 {
10881 let (source, _) = sources[0].clone();
10882 let opts = self.state.market_list_item_options(&source);
10883 if opts.is_empty() {
10884 self.state.push_log("Nothing loose to list.");
10885 return;
10886 }
10887 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10888 self.state.market_buy_confirm = None;
10889 return;
10890 }
10891 self.state.market_buy_confirm = None;
10892 self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
10893 }
10894
10895 pub fn market_ui_back(&mut self) {
10896 self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
10897 MarketUiMode::Browse => MarketUiMode::Browse,
10898 MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
10899 MarketUiMode::ListPick { .. } => {
10900 if self.state.market_list_source_options().len() <= 1 {
10901 MarketUiMode::Browse
10902 } else {
10903 MarketUiMode::ListSource { index: 0 }
10904 }
10905 }
10906 MarketUiMode::ListAmount {
10907 source, pick_index, ..
10908 } => MarketUiMode::ListPick {
10909 source,
10910 index: pick_index,
10911 },
10912 MarketUiMode::ListPricingMode {
10913 source,
10914 item_instance_id,
10915 template_id,
10916 label,
10917 max_qty,
10918 quantity,
10919 pick_index,
10920 ..
10921 } => {
10922 let input = quantity.map(|q| q.to_string()).unwrap_or_default();
10923 MarketUiMode::ListAmount {
10924 source,
10925 pick_index,
10926 item_instance_id,
10927 template_id,
10928 label,
10929 max_qty,
10930 input,
10931 }
10932 }
10933 MarketUiMode::ListPrice {
10934 source,
10935 pick_index,
10936 item_instance_id,
10937 template_id,
10938 label,
10939 max_qty,
10940 quantity,
10941 ..
10942 } => MarketUiMode::ListPricingMode {
10943 source,
10944 pick_index,
10945 item_instance_id,
10946 template_id,
10947 label,
10948 quantity,
10949 max_qty,
10950 index: 1,
10951 },
10952 };
10953 }
10954
10955 pub fn market_list_move(&mut self, delta: i32) {
10956 match &self.state.market_ui_mode {
10957 MarketUiMode::ListSource { index } => {
10958 let n = self.state.market_list_source_options().len();
10959 if n == 0 {
10960 return;
10961 }
10962 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10963 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
10964 }
10965 MarketUiMode::ListPricingMode { index, .. } => {
10966 let next = (*index as i32 + delta).rem_euclid(2) as usize;
10967 if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
10968 {
10969 *index = next;
10970 }
10971 }
10972 MarketUiMode::ListPick { source, index } => {
10973 let opts = self.state.market_list_item_options(source);
10974 let n = opts.len();
10975 if n == 0 {
10976 return;
10977 }
10978 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10979 self.state.market_ui_mode = MarketUiMode::ListPick {
10980 source: source.clone(),
10981 index: next,
10982 };
10983 }
10984 _ => {}
10985 }
10986 }
10987
10988 pub fn market_list_amount_append_char(&mut self, c: char) {
10989 if !c.is_ascii_digit() {
10990 return;
10991 }
10992 match &mut self.state.market_ui_mode {
10993 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
10994 if input.len() < 12 {
10995 input.push(c);
10996 }
10997 }
10998 _ => {}
10999 }
11000 }
11001
11002 pub fn market_list_amount_backspace(&mut self) {
11003 match &mut self.state.market_ui_mode {
11004 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
11005 input.pop();
11006 }
11007 _ => {}
11008 }
11009 }
11010
11011 pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
11012 match self.state.market_ui_mode.clone() {
11013 MarketUiMode::Browse => Ok(()),
11014 MarketUiMode::ListSource { index } => {
11015 let sources = self.state.market_list_source_options();
11016 let Some((source, _)) = sources.get(index).cloned() else {
11017 return Ok(());
11018 };
11019 let opts = self.state.market_list_item_options(&source);
11020 if opts.is_empty() {
11021 self.state.push_log("Nothing to list from that source.");
11022 return Ok(());
11023 }
11024 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11025 Ok(())
11026 }
11027 MarketUiMode::ListPick { source, index } => {
11028 let opts = self.state.market_list_item_options(&source);
11029 let Some(opt) = opts.get(index) else {
11030 self.state.push_log("Nothing to list.");
11031 self.state.market_ui_mode = MarketUiMode::Browse;
11032 return Ok(());
11033 };
11034 self.state.market_ui_mode = MarketUiMode::ListAmount {
11035 source,
11036 pick_index: index,
11037 item_instance_id: opt.item_instance_id,
11038 template_id: opt.template_id.clone(),
11039 label: opt.label.clone(),
11040 max_qty: opt.quantity.max(1),
11041 input: String::new(),
11042 };
11043 Ok(())
11044 }
11045 MarketUiMode::ListAmount {
11046 source,
11047 pick_index,
11048 item_instance_id,
11049 template_id,
11050 label,
11051 max_qty,
11052 input,
11053 ..
11054 } => {
11055 let Some(qty_opt) = parse_storage_quantity(&input) else {
11056 self.state.push_log("Enter a quantity (blank = all).");
11057 return Ok(());
11058 };
11059 if let Some(q) = qty_opt {
11060 if q > max_qty {
11061 self.state.push_log(format!("Only {max_qty} available."));
11062 return Ok(());
11063 }
11064 }
11065 self.state.market_ui_mode = MarketUiMode::ListPricingMode {
11066 source,
11067 pick_index,
11068 item_instance_id,
11069 template_id,
11070 label,
11071 quantity: qty_opt,
11072 max_qty,
11073 index: 0,
11074 };
11075 Ok(())
11076 }
11077 MarketUiMode::ListPricingMode {
11078 source,
11079 pick_index,
11080 item_instance_id,
11081 template_id,
11082 label,
11083 quantity,
11084 max_qty,
11085 index,
11086 } => {
11087 if index == 0 {
11088 if self
11089 .state
11090 .npc_market_dump_unit_estimate(&template_id)
11091 .is_none()
11092 {
11093 self.state
11094 .push_log("That item has no NPC value — use a fixed price instead.");
11095 return Ok(());
11096 }
11097 return self
11098 .submit_market_list_intent(
11099 source,
11100 item_instance_id,
11101 quantity,
11102 0,
11103 true,
11104 &label,
11105 )
11106 .await;
11107 }
11108 self.state.market_ui_mode = MarketUiMode::ListPrice {
11109 source,
11110 pick_index,
11111 item_instance_id,
11112 template_id,
11113 label,
11114 quantity,
11115 max_qty,
11116 input: String::new(),
11117 };
11118 Ok(())
11119 }
11120 MarketUiMode::ListPrice {
11121 source,
11122 item_instance_id,
11123 label,
11124 quantity,
11125 input,
11126 ..
11127 } => {
11128 let price = input.trim().parse::<u64>().unwrap_or(0);
11129 if price == 0 {
11130 self.state
11131 .push_log("Enter a unit price of at least 1 copper.");
11132 return Ok(());
11133 }
11134 self.submit_market_list_intent(
11135 source,
11136 item_instance_id,
11137 quantity,
11138 price,
11139 false,
11140 &label,
11141 )
11142 .await
11143 }
11144 }
11145 }
11146
11147 async fn submit_market_list_intent(
11148 &mut self,
11149 source: MarketListSourceKind,
11150 item_instance_id: uuid::Uuid,
11151 quantity: Option<u32>,
11152 unit_price_copper: u64,
11153 npc_price: bool,
11154 label: &str,
11155 ) -> anyhow::Result<()> {
11156 let Some(panel) = self.state.market_panel.clone() else {
11157 self.state.market_ui_mode = MarketUiMode::Browse;
11158 return Ok(());
11159 };
11160 let goods = match source {
11161 MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
11162 MarketListSourceKind::TownStorage { building_id } => {
11163 flatland_protocol::GoodsLocation::TownStorage { building_id }
11164 }
11165 };
11166 self.seq += 1;
11167 self.session
11168 .submit_intent(Intent::MarketList {
11169 entity_id: self.state.entity_id,
11170 npc_id: panel.npc_id,
11171 source: goods,
11172 item_instance_id,
11173 quantity,
11174 unit_price_copper,
11175 npc_price,
11176 seq: self.seq,
11177 })
11178 .await?;
11179 self.state.intents_sent += 1;
11180 if npc_price {
11181 self.state
11182 .push_log(format!("Listing {label} at NPC price…"));
11183 } else {
11184 self.state
11185 .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
11186 }
11187 self.state.market_ui_mode = MarketUiMode::Browse;
11188 Ok(())
11189 }
11190
11191 pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
11193 let return_to_verbs = self.state.npc_verb_target.is_some();
11194 self.close_shop_menu().await?;
11195 if return_to_verbs {
11196 self.state.show_npc_verb_menu = true;
11197 self.state.npc_verb_notice = None;
11198 }
11199 Ok(())
11200 }
11201
11202 pub fn shop_tab_toggle(&mut self) {
11203 self.state.shop_tab = match self.state.shop_tab {
11204 ShopTab::Buy => ShopTab::Sell,
11205 ShopTab::Sell => ShopTab::Buy,
11206 };
11207 self.state.shop_menu_index = 0;
11208 if self.state.shop_tab == ShopTab::Sell {
11209 self.state.shop_quantity_set_max();
11210 }
11211 self.state.clamp_shop_selection();
11212 }
11213
11214 pub fn shop_menu_move(&mut self, delta: i32) {
11215 self.state.shop_menu_move(delta);
11216 }
11217
11218 pub fn shop_quantity_adjust(&mut self, delta: i32) {
11219 self.state.shop_quantity_adjust(delta);
11220 }
11221
11222 pub fn shop_quantity_set_max(&mut self) {
11223 self.state.shop_quantity_set_max();
11224 }
11225
11226 pub fn shop_quantity_set_min(&mut self) {
11227 self.state.shop_quantity_set_min();
11228 }
11229
11230 pub fn toggle_quest_menu(&mut self) {
11231 self.state.show_quest_menu = !self.state.show_quest_menu;
11232 if self.state.show_quest_menu {
11233 self.state.quest_menu_index = 0;
11234 self.state.quest_withdraw_confirm = false;
11235 self.state.show_workers_menu = false;
11236 }
11237 }
11238
11239 pub fn toggle_workers_menu(&mut self) {
11240 if self.state.show_workers_menu {
11241 self.close_workers_menu_ui();
11242 } else {
11243 self.state.show_workers_menu = true;
11244 if self.state.workers_menu_index >= self.state.hired_workers.len() {
11246 self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11247 }
11248 self.state.show_quest_menu = false;
11249 self.close_worker_give_picker();
11250 self.close_worker_give_target_picker();
11251 self.close_worker_take_picker();
11252 self.close_worker_teach_picker();
11253 self.cancel_worker_rename();
11254 }
11255 }
11256
11257 pub fn close_workers_menu_ui(&mut self) {
11259 self.state.show_workers_menu = false;
11260 self.cancel_worker_dismissal();
11261 self.close_worker_give_picker();
11262 self.close_worker_give_target_picker();
11263 self.close_worker_take_picker();
11264 self.close_worker_teach_picker();
11265 self.cancel_worker_rename();
11266 }
11267
11268 pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
11270 let Some(idx) = self
11271 .state
11272 .hired_workers
11273 .iter()
11274 .position(|w| w.instance_id == instance_id)
11275 else {
11276 anyhow::bail!("worker not found");
11277 };
11278 let label = self.state.hired_workers[idx].label.clone();
11279 self.state.show_workers_menu = true;
11280 self.state.workers_menu_index = idx;
11281 self.state.show_quest_menu = false;
11282 self.close_worker_give_picker();
11283 self.close_worker_give_target_picker();
11284 self.close_worker_take_picker();
11285 self.close_worker_teach_picker();
11286 self.cancel_worker_rename();
11287 self.set_worker_attending(instance_id, true).await?;
11288 self.state
11289 .push_log(format!("Managing {label} — job paused while menu is open"));
11290 Ok(())
11291 }
11292
11293 pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
11295 self.close_workers_menu_ui();
11296 self.release_worker_attend().await
11297 }
11298
11299 async fn set_worker_attending(
11300 &mut self,
11301 instance_id: &str,
11302 attending: bool,
11303 ) -> anyhow::Result<()> {
11304 if attending {
11305 if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11306 return Ok(());
11307 }
11308 if let Some(prev) = self.state.attending_worker_instance_id.clone() {
11310 if prev != instance_id {
11311 self.send_attend_hired_worker(&prev, false).await?;
11312 }
11313 }
11314 self.send_attend_hired_worker(instance_id, true).await?;
11315 self.state.attending_worker_instance_id = Some(instance_id.to_string());
11316 } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11317 self.send_attend_hired_worker(instance_id, false).await?;
11318 self.state.attending_worker_instance_id = None;
11319 }
11320 Ok(())
11321 }
11322
11323 pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
11324 let Some(id) = self.state.attending_worker_instance_id.take() else {
11325 return Ok(());
11326 };
11327 self.send_attend_hired_worker(&id, false).await
11328 }
11329
11330 async fn send_attend_hired_worker(
11331 &mut self,
11332 worker_instance_id: &str,
11333 attending: bool,
11334 ) -> anyhow::Result<()> {
11335 self.seq += 1;
11336 self.session
11337 .submit_intent(Intent::AttendHiredWorker {
11338 entity_id: self.state.entity_id,
11339 worker_instance_id: worker_instance_id.to_string(),
11340 attending,
11341 seq: self.seq,
11342 })
11343 .await?;
11344 self.state.intents_sent += 1;
11345 Ok(())
11346 }
11347
11348 pub fn workers_menu_move(&mut self, delta: i32) {
11349 let n = self.state.hired_workers.len();
11350 if n == 0 {
11351 return;
11352 }
11353 let idx = self.state.workers_menu_index as i32;
11354 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
11355 }
11356
11357 pub fn toggle_workers_menu_compact(&mut self) {
11358 self.state.workers_menu_compact = !self.state.workers_menu_compact;
11359 let mut cfg = crate::client_config::ClientConfig::load();
11360 let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
11361 }
11362
11363 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
11364 let Some(worker) = self
11365 .state
11366 .hired_workers
11367 .get(self.state.workers_menu_index)
11368 .cloned()
11369 else {
11370 anyhow::bail!("no worker selected");
11371 };
11372 self.dismiss_worker_by_id(&worker.instance_id, &worker.label)
11373 .await
11374 }
11375
11376 pub fn request_worker_dismissal(&mut self) -> anyhow::Result<()> {
11378 let Some(worker) = self
11379 .state
11380 .hired_workers
11381 .get(self.state.workers_menu_index)
11382 .cloned()
11383 else {
11384 anyhow::bail!("no worker selected");
11385 };
11386 self.state.worker_dismiss_confirmation = Some(WorkerDismissConfirmation {
11387 worker_instance_id: worker.instance_id,
11388 worker_label: worker.label,
11389 });
11390 Ok(())
11391 }
11392
11393 pub fn cancel_worker_dismissal(&mut self) {
11394 self.state.worker_dismiss_confirmation = None;
11395 }
11396
11397 pub async fn confirm_worker_dismissal(&mut self) -> anyhow::Result<()> {
11398 let Some(confirm) = self.state.worker_dismiss_confirmation.clone() else {
11399 return Ok(());
11400 };
11401 self.dismiss_worker_by_id(&confirm.worker_instance_id, &confirm.worker_label)
11402 .await?;
11403 self.cancel_worker_dismissal();
11404 Ok(())
11405 }
11406
11407 async fn dismiss_worker_by_id(
11408 &mut self,
11409 worker_instance_id: &str,
11410 worker_label: &str,
11411 ) -> anyhow::Result<()> {
11412 self.seq += 1;
11413 self.session
11414 .submit_intent(Intent::DismissWorker {
11415 entity_id: self.state.entity_id,
11416 worker_instance_id: worker_instance_id.to_string(),
11417 seq: self.seq,
11418 })
11419 .await?;
11420 self.state.intents_sent += 1;
11421 self.state
11422 .hired_workers
11423 .retain(|w| w.instance_id != worker_instance_id);
11424 if self.state.workers_menu_index >= self.state.hired_workers.len() {
11425 self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11426 }
11427 self.state.push_log(format!("Dismissed {worker_label}"));
11428 Ok(())
11429 }
11430
11431 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
11432 let Some(worker) = self
11433 .state
11434 .hired_workers
11435 .get(self.state.workers_menu_index)
11436 .cloned()
11437 else {
11438 anyhow::bail!("no worker selected");
11439 };
11440 let mode = match worker.mode {
11441 flatland_protocol::WorkerModeView::Companion => "defender",
11442 flatland_protocol::WorkerModeView::Defender => "job_loop",
11443 flatland_protocol::WorkerModeView::JobLoop => "idle",
11444 flatland_protocol::WorkerModeView::Idle => "companion",
11445 };
11446 self.seq += 1;
11447 self.session
11448 .submit_intent(Intent::SetWorkerMode {
11449 entity_id: self.state.entity_id,
11450 worker_instance_id: worker.instance_id,
11451 mode: mode.into(),
11452 seq: self.seq,
11453 })
11454 .await?;
11455 self.state.intents_sent += 1;
11456 Ok(())
11457 }
11458
11459 pub async fn workers_deliver_selected_to_storage(&mut self) -> anyhow::Result<()> {
11460 let Some(worker) = self
11461 .state
11462 .hired_workers
11463 .get(self.state.workers_menu_index)
11464 .cloned()
11465 else {
11466 anyhow::bail!("no worker selected");
11467 };
11468 if !matches!(worker.mode, flatland_protocol::WorkerModeView::Companion) {
11469 anyhow::bail!("switch the worker to companion mode first");
11470 }
11471 if worker.step_label.starts_with("delivering to ")
11472 || worker.step_label == "returning to you"
11473 {
11474 anyhow::bail!("worker is already delivering to storage");
11475 }
11476 self.seq += 1;
11477 self.session
11478 .submit_intent(Intent::DeliverWorkerToNearestStorage {
11479 entity_id: self.state.entity_id,
11480 worker_instance_id: worker.instance_id.clone(),
11481 seq: self.seq,
11482 })
11483 .await?;
11484 self.state.intents_sent += 1;
11485 self.state.push_log(format!(
11486 "{} is delivering carried items to storage",
11487 worker.label
11488 ));
11489 Ok(())
11490 }
11491
11492 pub async fn workers_cancel_delivery_selected(&mut self) -> anyhow::Result<()> {
11493 let Some(worker) = self
11494 .state
11495 .hired_workers
11496 .get(self.state.workers_menu_index)
11497 .cloned()
11498 else {
11499 anyhow::bail!("no worker selected");
11500 };
11501 if !(worker.step_label.starts_with("delivering to ")
11502 || worker.step_label == "returning to you")
11503 {
11504 anyhow::bail!("worker has no active delivery");
11505 }
11506 self.seq += 1;
11507 self.session
11508 .submit_intent(Intent::CancelWorkerDelivery {
11509 entity_id: self.state.entity_id,
11510 worker_instance_id: worker.instance_id,
11511 seq: self.seq,
11512 })
11513 .await?;
11514 self.state.intents_sent += 1;
11515 self.state
11516 .push_log(format!("Canceled delivery for {}", worker.label));
11517 Ok(())
11518 }
11519
11520 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
11521 if self.state.hired_workers.is_empty() {
11522 return self.hire_worker_laborer().await;
11523 }
11524 self.workers_toggle_mode_selected().await
11525 }
11526
11527 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11530 let row = self
11531 .state
11532 .inventory_selected_row()
11533 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
11534 .clone();
11535 if row.from != flatland_protocol::InventoryLocation::Root {
11536 anyhow::bail!("select a carried item to give");
11537 }
11538 let Some(instance_id) = row.stack.item_instance_id else {
11539 anyhow::bail!("that stack can't be given");
11540 };
11541 let options = self.nearby_worker_give_targets();
11542 if options.is_empty() {
11543 anyhow::bail!(
11544 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
11545 );
11546 }
11547 let item_label = row
11548 .stack
11549 .display_name
11550 .as_deref()
11551 .unwrap_or(&row.stack.template_id)
11552 .to_string();
11553 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
11554 item_instance_id: instance_id,
11555 item_label,
11556 quantity: None,
11557 options,
11558 });
11559 self.state.worker_give_target_picker_index = 0;
11560 self.state.show_worker_give_target_picker = true;
11561 self.state.show_inventory_menu = false;
11563 Ok(())
11564 }
11565
11566 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
11568 let (px, py, _) = self.state.player_position_with_z();
11569 let mut options: Vec<WorkerGiveTargetOption> = self
11570 .state
11571 .hired_workers
11572 .iter()
11573 .filter_map(|w| {
11574 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
11575 if dist > WORKER_GIVE_RANGE_M {
11576 return None;
11577 }
11578 Some(WorkerGiveTargetOption {
11579 instance_id: w.instance_id.clone(),
11580 label: w.label.clone(),
11581 distance_m: dist,
11582 })
11583 })
11584 .collect();
11585 options.sort_by(|a, b| {
11586 a.distance_m
11587 .partial_cmp(&b.distance_m)
11588 .unwrap_or(std::cmp::Ordering::Equal)
11589 });
11590 options
11591 }
11592
11593 pub fn close_worker_give_target_picker(&mut self) {
11594 self.state.show_worker_give_target_picker = false;
11595 self.state.worker_give_target_picker = None;
11596 self.state.worker_give_target_picker_index = 0;
11597 }
11598
11599 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
11600 let Some(picker) = &self.state.worker_give_target_picker else {
11601 return;
11602 };
11603 let n = picker.options.len();
11604 if n == 0 {
11605 return;
11606 }
11607 let idx = self.state.worker_give_target_picker_index as i32;
11608 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11609 }
11610
11611 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11612 let Some(picker) = self.state.worker_give_target_picker.clone() else {
11613 anyhow::bail!("give target picker not open");
11614 };
11615 let Some(opt) = picker
11616 .options
11617 .get(self.state.worker_give_target_picker_index)
11618 .cloned()
11619 else {
11620 anyhow::bail!("no worker selected");
11621 };
11622 let Some(worker) = self
11623 .state
11624 .hired_workers
11625 .iter()
11626 .find(|w| w.instance_id == opt.instance_id)
11627 .cloned()
11628 else {
11629 self.close_worker_give_target_picker();
11630 anyhow::bail!("worker no longer hired");
11631 };
11632 self.give_item_to_worker(
11633 &worker.instance_id,
11634 &worker.label,
11635 worker.x,
11636 worker.y,
11637 picker.item_instance_id,
11638 &picker.item_label,
11639 picker.quantity,
11640 )
11641 .await?;
11642 self.close_worker_give_target_picker();
11643 Ok(())
11644 }
11645
11646 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
11648 self.open_worker_give_target_picker()
11649 }
11650
11651 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
11653 let Some(worker) = self
11654 .state
11655 .hired_workers
11656 .get(self.state.workers_menu_index)
11657 .cloned()
11658 else {
11659 anyhow::bail!("select a hired worker first");
11660 };
11661 let (px, py, _) = self.state.player_position_with_z();
11662 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11663 if dist > WORKER_GIVE_RANGE_M {
11664 anyhow::bail!(
11665 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
11666 worker.label
11667 );
11668 }
11669 let options = self.state.giveable_inventory_options();
11670 if options.is_empty() {
11671 anyhow::bail!("nothing in inventory to give");
11672 }
11673 self.state.worker_give_picker = Some(WorkerGivePicker {
11674 worker_instance_id: worker.instance_id,
11675 worker_label: worker.label,
11676 options,
11677 });
11678 self.state.worker_give_picker_index = 0;
11679 self.state.show_worker_give_picker = true;
11680 Ok(())
11681 }
11682
11683 pub fn close_worker_give_picker(&mut self) {
11684 self.state.show_worker_give_picker = false;
11685 self.state.worker_give_picker = None;
11686 self.state.worker_give_picker_index = 0;
11687 }
11688
11689 pub fn worker_give_picker_move(&mut self, delta: i32) {
11690 let Some(picker) = &self.state.worker_give_picker else {
11691 return;
11692 };
11693 let n = picker.options.len();
11694 if n == 0 {
11695 return;
11696 }
11697 let idx = self.state.worker_give_picker_index as i32;
11698 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11699 }
11700
11701 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
11703 let Some(picker) = self.state.worker_give_picker.clone() else {
11704 anyhow::bail!("give picker not open");
11705 };
11706 let Some(opt) = picker
11707 .options
11708 .get(self.state.worker_give_picker_index)
11709 .cloned()
11710 else {
11711 anyhow::bail!("no item selected");
11712 };
11713 let Some(worker) = self
11714 .state
11715 .hired_workers
11716 .iter()
11717 .find(|w| w.instance_id == picker.worker_instance_id)
11718 .cloned()
11719 else {
11720 self.close_worker_give_picker();
11721 anyhow::bail!("worker no longer hired");
11722 };
11723 self.give_item_to_worker(
11724 &worker.instance_id,
11725 &worker.label,
11726 worker.x,
11727 worker.y,
11728 opt.item_instance_id,
11729 &opt.label,
11730 None,
11731 )
11732 .await?;
11733 let options = self.state.giveable_inventory_options();
11735 if options.is_empty() {
11736 self.close_worker_give_picker();
11737 } else {
11738 self.state.worker_give_picker = Some(WorkerGivePicker {
11739 worker_instance_id: picker.worker_instance_id,
11740 worker_label: picker.worker_label,
11741 options,
11742 });
11743 if self.state.worker_give_picker_index
11744 >= self
11745 .state
11746 .worker_give_picker
11747 .as_ref()
11748 .map(|p| p.options.len())
11749 .unwrap_or(0)
11750 {
11751 self.state.worker_give_picker_index = self
11752 .state
11753 .worker_give_picker
11754 .as_ref()
11755 .map(|p| p.options.len().saturating_sub(1))
11756 .unwrap_or(0);
11757 }
11758 }
11759 Ok(())
11760 }
11761
11762 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11764 let Some(worker) = self
11765 .state
11766 .hired_workers
11767 .get(self.state.workers_menu_index)
11768 .cloned()
11769 else {
11770 anyhow::bail!("select a hired worker first");
11771 };
11772 let (px, py, _) = self.state.player_position_with_z();
11773 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11774 if dist > WORKER_GIVE_RANGE_M {
11775 anyhow::bail!(
11776 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
11777 worker.label
11778 );
11779 }
11780 let options = self.state.teachable_blueprint_options(&worker);
11781 if options.is_empty() {
11782 anyhow::bail!("no recipes you know that {} still needs", worker.label);
11783 }
11784 self.state.worker_teach_picker = Some(WorkerTeachPicker {
11785 worker_instance_id: worker.instance_id,
11786 worker_label: worker.label,
11787 worker_level: worker.level,
11788 options,
11789 });
11790 self.state.worker_teach_picker_index = 0;
11791 self.state.show_worker_teach_picker = true;
11792 Ok(())
11793 }
11794
11795 pub fn close_worker_teach_picker(&mut self) {
11796 self.state.show_worker_teach_picker = false;
11797 self.state.worker_teach_picker = None;
11798 self.state.worker_teach_picker_index = 0;
11799 }
11800
11801 pub fn worker_teach_picker_move(&mut self, delta: i32) {
11802 let Some(picker) = &self.state.worker_teach_picker else {
11803 return;
11804 };
11805 let n = picker.options.len();
11806 if n == 0 {
11807 return;
11808 }
11809 let idx = self.state.worker_teach_picker_index as i32;
11810 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11811 }
11812
11813 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11814 let Some(picker) = self.state.worker_teach_picker.clone() else {
11815 anyhow::bail!("teach picker not open");
11816 };
11817 let Some(opt) = picker
11818 .options
11819 .get(self.state.worker_teach_picker_index)
11820 .cloned()
11821 else {
11822 anyhow::bail!("nothing selected");
11823 };
11824 if !opt.level_ok {
11825 anyhow::bail!(
11826 "{} needs level {} (is level {})",
11827 picker.worker_label,
11828 opt.min_level,
11829 opt.worker_level
11830 );
11831 }
11832 if !opt.can_afford {
11833 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
11834 }
11835 let Some(worker) = self
11836 .state
11837 .hired_workers
11838 .iter()
11839 .find(|w| w.instance_id == picker.worker_instance_id)
11840 .cloned()
11841 else {
11842 anyhow::bail!("worker gone");
11843 };
11844 let (px, py, _) = self.state.player_position_with_z();
11845 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11846 if dist > WORKER_GIVE_RANGE_M {
11847 anyhow::bail!("worker {} too far — stand next to them", worker.label);
11848 }
11849 self.seq += 1;
11850 self.session
11851 .submit_intent(Intent::TeachWorkerBlueprint {
11852 entity_id: self.state.entity_id,
11853 worker_instance_id: picker.worker_instance_id.clone(),
11854 blueprint_id: opt.blueprint_id.clone(),
11855 seq: self.seq,
11856 })
11857 .await?;
11858 self.state.intents_sent += 1;
11859 self.state.push_log(format!(
11860 "Teaching {} to {} ({} cp)",
11861 opt.label, picker.worker_label, opt.cost_copper
11862 ));
11863 self.close_worker_teach_picker();
11864 Ok(())
11865 }
11866
11867 async fn give_item_to_worker(
11868 &mut self,
11869 worker_instance_id: &str,
11870 worker_label: &str,
11871 worker_x: f32,
11872 worker_y: f32,
11873 item_instance_id: uuid::Uuid,
11874 item_label: &str,
11875 quantity: Option<u32>,
11876 ) -> anyhow::Result<()> {
11877 let (px, py, _) = self.state.player_position_with_z();
11878 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
11879 if dist > WORKER_GIVE_RANGE_M {
11880 anyhow::bail!("worker {worker_label} too far — stand next to them");
11881 }
11882 self.seq += 1;
11883 self.session
11884 .submit_intent(Intent::GiveWorkerItem {
11885 entity_id: self.state.entity_id,
11886 worker_instance_id: worker_instance_id.to_string(),
11887 item_instance_id,
11888 quantity,
11889 seq: self.seq,
11890 })
11891 .await?;
11892 self.state.intents_sent += 1;
11893 self.state
11894 .remove_carried_instance(item_instance_id, quantity);
11895 self.state
11896 .push_log(format!("Gave {item_label} to {worker_label}"));
11897 Ok(())
11898 }
11899
11900 pub async fn equip_item_on_worker(
11904 &mut self,
11905 worker_instance_id: &str,
11906 item_instance_id: uuid::Uuid,
11907 slot: &str,
11908 ) -> anyhow::Result<()> {
11909 let Some(worker) = self
11910 .state
11911 .hired_workers
11912 .iter()
11913 .find(|worker| worker.instance_id == worker_instance_id)
11914 .cloned()
11915 else {
11916 anyhow::bail!("worker not found");
11917 };
11918 let (px, py, _) = self.state.player_position_with_z();
11919 if (worker.x - px).hypot(worker.y - py) > WORKER_GIVE_RANGE_M {
11920 anyhow::bail!("worker {} too far — stand next to them", worker.label);
11921 }
11922 self.seq += 1;
11923 self.session
11924 .submit_intent(Intent::EquipWorkerItem {
11925 entity_id: self.state.entity_id,
11926 worker_instance_id: worker.instance_id.clone(),
11927 item_instance_id,
11928 slot: slot.to_string(),
11929 seq: self.seq,
11930 })
11931 .await?;
11932 self.state.intents_sent += 1;
11933 self.state
11934 .push_log(format!("Equipped {slot} on {}", worker.label));
11935 Ok(())
11936 }
11937
11938 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
11940 let Some(worker) = self
11941 .state
11942 .hired_workers
11943 .get(self.state.workers_menu_index)
11944 .cloned()
11945 else {
11946 anyhow::bail!("select a hired worker first");
11947 };
11948 let (px, py, _) = self.state.player_position_with_z();
11949 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11950 if dist > WORKER_GIVE_RANGE_M {
11951 anyhow::bail!(
11952 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
11953 worker.label
11954 );
11955 }
11956 let options = Self::worker_inventory_options(&worker);
11957 if options.is_empty() {
11958 anyhow::bail!("{} isn't carrying anything", worker.label);
11959 }
11960 let initial_qty = options
11961 .first()
11962 .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
11963 .unwrap_or(1);
11964 self.state.worker_take_picker = Some(WorkerTakePicker {
11965 worker_instance_id: worker.instance_id,
11966 worker_label: worker.label,
11967 options,
11968 quantity: initial_qty,
11969 });
11970 self.state.worker_take_picker_index = 0;
11971 self.state.show_worker_take_picker = true;
11972 Ok(())
11973 }
11974
11975 fn worker_inventory_options(
11976 worker: &flatland_protocol::HiredWorkerView,
11977 ) -> Vec<WorkerGiveOption> {
11978 worker
11979 .inventory
11980 .iter()
11981 .filter_map(|stack| {
11982 let item_instance_id = stack.item_instance_id?;
11983 let label = stack
11984 .display_name
11985 .clone()
11986 .unwrap_or_else(|| stack.template_id.clone());
11987 let label = if stack.quantity > 1 {
11988 format!("{label} ×{}", stack.quantity)
11989 } else {
11990 label
11991 };
11992 Some(WorkerGiveOption {
11993 item_instance_id,
11994 label,
11995 quantity: stack.quantity,
11996 template_id: stack.template_id.clone(),
11997 })
11998 })
11999 .collect()
12000 }
12001
12002 pub fn close_worker_take_picker(&mut self) {
12003 self.state.show_worker_take_picker = false;
12004 self.state.worker_take_picker = None;
12005 self.state.worker_take_picker_index = 0;
12006 }
12007
12008 pub fn worker_take_picker_move(&mut self, delta: i32) {
12009 let Some(picker) = &self.state.worker_take_picker else {
12010 return;
12011 };
12012 let n = picker.options.len();
12013 if n == 0 {
12014 return;
12015 }
12016 let idx = self.state.worker_take_picker_index as i32;
12017 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
12018 self.clamp_worker_take_quantity();
12019 }
12020
12021 pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
12022 let Some(picker) = &mut self.state.worker_take_picker else {
12023 return;
12024 };
12025 let max = picker
12026 .options
12027 .get(self.state.worker_take_picker_index)
12028 .map(|o| o.quantity.max(1))
12029 .unwrap_or(1);
12030 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
12031 picker.quantity = next as u32;
12032 }
12033
12034 pub fn worker_take_picker_set_quantity_max(&mut self) {
12035 let Some(picker) = &mut self.state.worker_take_picker else {
12036 return;
12037 };
12038 let max = picker
12039 .options
12040 .get(self.state.worker_take_picker_index)
12041 .map(|o| o.quantity.max(1))
12042 .unwrap_or(1);
12043 picker.quantity = max;
12044 }
12045
12046 pub fn worker_take_picker_set_quantity_min(&mut self) {
12047 let Some(picker) = &mut self.state.worker_take_picker else {
12048 return;
12049 };
12050 picker.quantity = 1;
12051 self.clamp_worker_take_quantity();
12052 }
12053
12054 fn clamp_worker_take_quantity(&mut self) {
12055 let Some(picker) = &mut self.state.worker_take_picker else {
12056 return;
12057 };
12058 let max = picker
12059 .options
12060 .get(self.state.worker_take_picker_index)
12061 .map(|o| o.quantity.max(1))
12062 .unwrap_or(1);
12063 if picker.quantity == 0 || picker.quantity > max {
12064 picker.quantity = if max > 1 { 1 } else { max };
12065 }
12066 }
12067
12068 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
12069 let Some(picker) = self.state.worker_take_picker.clone() else {
12070 anyhow::bail!("take picker not open");
12071 };
12072 let Some(opt) = picker
12073 .options
12074 .get(self.state.worker_take_picker_index)
12075 .cloned()
12076 else {
12077 anyhow::bail!("no item selected");
12078 };
12079 let Some(worker) = self
12080 .state
12081 .hired_workers
12082 .iter()
12083 .find(|w| w.instance_id == picker.worker_instance_id)
12084 .cloned()
12085 else {
12086 self.close_worker_take_picker();
12087 anyhow::bail!("worker no longer hired");
12088 };
12089 let qty = picker.quantity.clamp(1, opt.quantity.max(1));
12090 let intent_qty = if qty >= opt.quantity { None } else { Some(qty) };
12091 self.take_item_from_worker(
12092 &worker.instance_id,
12093 &worker.label,
12094 worker.x,
12095 worker.y,
12096 opt.item_instance_id,
12097 &opt.label,
12098 intent_qty,
12099 )
12100 .await?;
12101 Ok(())
12104 }
12105
12106 async fn take_item_from_worker(
12107 &mut self,
12108 worker_instance_id: &str,
12109 worker_label: &str,
12110 worker_x: f32,
12111 worker_y: f32,
12112 item_instance_id: uuid::Uuid,
12113 item_label: &str,
12114 quantity: Option<u32>,
12115 ) -> anyhow::Result<()> {
12116 let (px, py, _) = self.state.player_position_with_z();
12117 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
12118 if dist > WORKER_GIVE_RANGE_M {
12119 anyhow::bail!("worker {worker_label} too far — stand next to them");
12120 }
12121 self.seq += 1;
12122 self.session
12123 .submit_intent(Intent::TakeWorkerItem {
12124 entity_id: self.state.entity_id,
12125 worker_instance_id: worker_instance_id.to_string(),
12126 item_instance_id,
12127 quantity,
12128 seq: self.seq,
12129 })
12130 .await?;
12131 self.state.intents_sent += 1;
12132 let qty_note = quantity.map(|q| format!(" ×{q}")).unwrap_or_default();
12133 self.state.push_log(format!(
12134 "Taking {item_label}{qty_note} from {worker_label}…"
12135 ));
12136 Ok(())
12137 }
12138
12139 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
12140 if let Some(since) = self.state.pending_worker_hire_since {
12141 if since.elapsed() < WORKER_HIRE_PENDING_TIMEOUT {
12142 anyhow::bail!("hire request still pending — wait for the worker roster update");
12143 }
12144 self.state.pending_worker_hire_since = None;
12145 }
12146 if !self.state.has_worker_lodging() {
12147 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
12148 }
12149 self.seq += 1;
12150 self.session
12151 .submit_intent(Intent::HireWorker {
12152 entity_id: self.state.entity_id,
12153 def_id: "worker_laborer".into(),
12154 wage_copper_per_interval: 8,
12155 lodging_container_id: None,
12156 job_yaml: None,
12157 seq: self.seq,
12158 })
12159 .await?;
12160 self.state.intents_sent += 1;
12161 self.state.pending_worker_hire_since = Some(Instant::now());
12162 Ok(())
12163 }
12164
12165 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
12166 let Some(worker) = self
12167 .state
12168 .hired_workers
12169 .get(self.state.workers_menu_index)
12170 .cloned()
12171 else {
12172 anyhow::bail!("select a hired worker first");
12173 };
12174 let lodging = worker.lodging_container_id.clone().or_else(|| {
12175 crate::worker_route_editor::owned_lodging_container_ids(
12176 &self.state.placed_containers,
12177 self.state.character_id,
12178 )
12179 .into_iter()
12180 .next()
12181 .map(|(id, _)| id)
12182 });
12183 let label = worker.label.clone();
12184 let editor = if let Some(route) = &worker.route {
12185 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
12186 worker.instance_id,
12187 worker.label,
12188 route,
12189 lodging,
12190 )
12191 } else {
12192 crate::worker_route_editor::WorkerRouteEditorState::new(
12193 worker.instance_id,
12194 worker.label,
12195 lodging,
12196 )
12197 };
12198 self.state.worker_route_editor = Some(editor);
12199 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12200 if let Some(collapsed) =
12201 crate::client_config::ClientConfig::load().worker_route_panel_collapsed
12202 {
12203 ed.panel_collapsed = collapsed;
12204 }
12205 }
12206 self.state.show_workers_menu = false;
12207 self.state.push_log(format!(
12208 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
12209 ));
12210 Ok(())
12211 }
12212
12213 pub fn close_worker_route_editor(&mut self) {
12214 self.state.worker_route_editor = None;
12215 }
12216
12217 pub fn worker_route_editor_toggle_panel(&mut self) {
12218 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12219 ed.toggle_panel_collapsed();
12220 let collapsed = ed.panel_collapsed;
12221 let mut cfg = crate::client_config::ClientConfig::load();
12222 let _ = cfg.save_worker_route_panel_collapsed(collapsed);
12223 }
12224 }
12225
12226 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
12227 let n = {
12228 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12229 return;
12230 };
12231 ed.append_waypoint(x, y, z);
12232 ed.stop_count()
12233 };
12234 self.state
12235 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
12236 }
12237
12238 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
12241 let (px, py, _) = self.state.player_position_with_z();
12242 let inside = self.state.effective_inside_building();
12243 crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
12244 &self.state.placed_containers,
12245 &self.state.buildings,
12246 self.state.character_id,
12247 px,
12248 py,
12249 &self.state.hired_workers,
12250 inside.as_deref(),
12251 )
12252 }
12253
12254 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
12255 self.state.route_editor_node_candidates()
12256 }
12257
12258 fn re_open_harvest_picker(&mut self, index: usize, picked: std::collections::BTreeSet<String>) {
12259 use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
12260 let nodes = self.state.route_editor_node_candidates();
12261 let index = if nodes.is_empty() {
12262 ROUTE_PICKER_DONE_ROW
12263 } else {
12264 index.max(1).min(nodes.len())
12265 };
12266 self.re_open_sheet(S::HarvestPicker {
12267 index,
12268 picked,
12269 nodes,
12270 });
12271 }
12272
12273 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
12274 let (px, py, _) = self.state.player_position_with_z();
12275 let templates = self.re_template_candidates();
12276 crate::worker_route_editor::trade_npc_candidates(
12277 &self.state.npcs,
12278 px,
12279 py,
12280 &templates,
12281 )
12282 }
12283
12284 fn re_template_candidates(&self) -> Vec<String> {
12285 let mut extra = Vec::new();
12286 if let Some(ed) = self.state.worker_route_editor.as_ref() {
12287 for stop in &ed.stops {
12288 match stop {
12289 crate::worker_route_editor::WorkerRouteStop::DepositAt {
12290 filter: Some(filter),
12291 ..
12292 } => extra.extend(filter.iter().cloned()),
12293 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. }
12294 | crate::worker_route_editor::WorkerRouteStop::ListOnMarket {
12295 template,
12296 ..
12297 } => {
12298 extra.push(template.clone());
12299 }
12300 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
12301 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint)
12302 {
12303 extra.push(bp.output.clone());
12304 for input in &bp.inputs {
12305 extra.push(input.template_id.clone());
12306 }
12307 }
12308 }
12309 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
12310 for it in items {
12311 extra.push(it.template.clone());
12312 }
12313 }
12314 _ => {}
12315 }
12316 }
12317 if let Some(worker) = self
12319 .state
12320 .hired_workers
12321 .iter()
12322 .find(|w| w.instance_id == ed.worker_instance_id)
12323 {
12324 for recipe in &worker.known_blueprint_ids {
12325 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
12326 extra.push(bp.output.clone());
12327 }
12328 }
12329 for stack in &worker.inventory {
12330 if !stack.template_id.is_empty() && stack.quantity > 0 {
12331 extra.push(stack.template_id.clone());
12332 }
12333 }
12334 }
12335 }
12336 crate::worker_route_editor::route_item_template_candidates(
12337 &self.state.placed_containers,
12338 self.state.character_id,
12339 &self.state.inventory,
12340 &self.state.blueprints,
12341 &self.state.resource_nodes,
12342 &extra,
12343 Some(&self.state.item_catalog),
12344 )
12345 }
12346
12347 fn re_blueprint_ids(&self) -> Vec<String> {
12348 let worker_known: Option<&[String]> = self
12349 .state
12350 .worker_route_editor
12351 .as_ref()
12352 .and_then(|ed| {
12353 self.state
12354 .hired_workers
12355 .iter()
12356 .find(|w| w.instance_id == ed.worker_instance_id)
12357 })
12358 .map(|w| w.known_blueprint_ids.as_slice());
12359 crate::worker_route_editor::worker_craft_blueprint_ids(&self.state.blueprints, worker_known)
12360 }
12361
12362 fn re_bed_candidates(&self) -> Vec<(String, String)> {
12363 crate::worker_route_editor::owned_lodging_container_ids(
12364 &self.state.placed_containers,
12365 self.state.character_id,
12366 )
12367 }
12368
12369 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
12370 self.state
12371 .placed_containers
12372 .iter()
12373 .find(|c| c.id == container_id)
12374 .map(|c| c.contents.clone())
12375 .unwrap_or_default()
12376 }
12377
12378 fn re_sheet_supports_filter(&self) -> bool {
12381 use crate::worker_route_editor::RouteEditorSheet as S;
12382 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12383 matches!(
12384 ed.sheet,
12385 S::HarvestPicker { .. }
12386 | S::SellItem { .. }
12387 | S::MarketListItem { .. }
12388 | S::DepositFilter { .. }
12389 | S::WithdrawItems { .. }
12390 | S::WithdrawContainers { .. }
12391 | S::DepositContainers { .. }
12392 | S::SellNpcs { .. }
12393 | S::CraftBlueprint { .. }
12394 | S::BedPicker { .. }
12395 )
12396 })
12397 }
12398
12399 pub fn re_sheet_row_visible(&self, row: usize) -> bool {
12401 use crate::worker_route_editor::{
12402 harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
12403 ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12404 };
12405 let Some(ed) = self.state.worker_route_editor.as_ref() else {
12406 return false;
12407 };
12408 let filter = &ed.sheet_filter;
12409 match &ed.sheet {
12410 S::HarvestPicker { nodes, .. } => harvest_picker_row_matches(nodes, row, filter),
12411 S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12412 if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
12413 return true;
12414 }
12415 let slot = row.saturating_sub(2);
12416 templates.get(slot).is_some_and(|t| {
12417 let label = self.state.template_display_name(t);
12418 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12419 })
12420 }
12421 S::DepositFilter { rows, .. } => {
12422 if row >= rows.len() {
12423 return true;
12424 }
12425 rows.get(row).is_some_and(|(t, _)| {
12426 let label = self.state.template_display_name(t);
12427 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12428 })
12429 }
12430 S::WithdrawItems { lines, .. } => {
12431 if row >= lines.len() {
12432 return true;
12433 }
12434 lines.get(row).is_some_and(|l| {
12435 let label = self.state.template_display_name(&l.template);
12436 list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
12437 })
12438 }
12439 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12440 self.re_container_candidates().get(row).is_some_and(|c| {
12441 list_filter_row_matches(
12442 filter,
12443 Some(c.dist),
12444 &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
12445 )
12446 })
12447 }
12448 S::SellNpcs { .. } => {
12449 if row == 0 {
12450 return true;
12451 }
12452 self.re_npc_candidates().get(row - 1).is_some_and(|n| {
12453 list_filter_row_matches(
12454 filter,
12455 Some(n.dist),
12456 &[n.label.as_str(), n.id.as_str()],
12457 )
12458 })
12459 }
12460 S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
12461 let label = self
12462 .state
12463 .blueprints
12464 .iter()
12465 .find(|b| &b.id == id)
12466 .map(|b| {
12467 if b.label.is_empty() {
12468 id.as_str()
12469 } else {
12470 b.label.as_str()
12471 }
12472 })
12473 .unwrap_or(id.as_str());
12474 list_filter_row_matches(filter, None, &[id.as_str(), label])
12475 }),
12476 S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
12477 list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
12478 }),
12479 _ => true,
12480 }
12481 }
12482
12483 fn re_sheet_clamp_index(&mut self) {
12484 let count = self.re_sheet_row_count();
12485 if count == 0 {
12486 return;
12487 }
12488 let cur = self.re_sheet_index();
12489 if self.re_sheet_row_visible(cur) {
12490 return;
12491 }
12492 for offset in 1..count {
12493 if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
12494 self.re_sheet_set_index(cur + offset);
12495 return;
12496 }
12497 if cur >= offset && self.re_sheet_row_visible(cur - offset) {
12498 self.re_sheet_set_index(cur - offset);
12499 return;
12500 }
12501 }
12502 }
12503
12504 fn re_sheet_set_index(&mut self, index: usize) {
12505 use crate::worker_route_editor::RouteEditorSheet as S;
12506 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12507 return;
12508 };
12509 match &mut ed.sheet {
12510 S::AddMenu { index: slot }
12511 | S::WaypointMenu { index: slot }
12512 | S::HarvestPicker { index: slot, .. }
12513 | S::WithdrawContainers { index: slot }
12514 | S::DepositContainers { index: slot }
12515 | S::SellNpcs { index: slot }
12516 | S::CraftBlueprint { index: slot }
12517 | S::BedPicker { index: slot }
12518 | S::FarmPlotPicker { index: slot, .. }
12519 | S::FarmPlantSeed { index: slot, .. }
12520 | S::WithdrawItems { index: slot, .. }
12521 | S::DepositFilter { index: slot, .. }
12522 | S::SellItem { index: slot, .. } | S::MarketListItem { index: slot, .. } => *slot = index,
12523 _ => {}
12524 }
12525 }
12526
12527 pub fn re_focus_sheet_filter(&mut self) {
12528 if !self.re_sheet_supports_filter() {
12529 return;
12530 }
12531 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12532 ed.sheet_filter_focused = true;
12533 }
12534 }
12535
12536 pub fn re_blur_sheet_filter_keep_text(&mut self) {
12537 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12538 return;
12539 };
12540 if !ed.sheet_filter_focused {
12541 return;
12542 }
12543 ed.sheet_filter_focused = false;
12544 self.re_sheet_clamp_index();
12545 }
12546
12547 pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
12548 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12549 return false;
12550 };
12551 if ed.sheet_filter_focused {
12552 ed.sheet_filter_focused = false;
12553 self.re_sheet_clamp_index();
12554 return true;
12555 }
12556 if !ed.sheet_filter.is_empty() {
12557 ed.sheet_filter.clear();
12558 self.re_sheet_clamp_index();
12559 return true;
12560 }
12561 false
12562 }
12563
12564 pub fn re_append_sheet_filter_char(&mut self, ch: char) {
12565 if ch.is_control() {
12566 return;
12567 }
12568 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12569 return;
12570 };
12571 if !ed.sheet_filter_focused {
12572 return;
12573 }
12574 ed.sheet_filter.push(ch);
12575 self.re_sheet_set_index(0);
12576 self.re_sheet_clamp_index();
12577 }
12578
12579 pub fn re_sheet_filter_backspace(&mut self) {
12580 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12581 return;
12582 };
12583 if !ed.sheet_filter_focused {
12584 return;
12585 }
12586 ed.sheet_filter.pop();
12587 self.re_sheet_set_index(0);
12588 self.re_sheet_clamp_index();
12589 }
12590
12591 pub fn re_sheet_row_count(&self) -> usize {
12593 use crate::worker_route_editor::{
12594 harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
12595 };
12596 let Some(ed) = self.state.worker_route_editor.as_ref() else {
12597 return 0;
12598 };
12599 match &ed.sheet {
12600 S::Stops => ed.stops.len(),
12601 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
12602 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
12603 S::WaypointMapPick => 0,
12604 S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
12605 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12606 self.re_container_candidates().len()
12607 }
12608 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, .. } => {
12612 sell_item_picker_row_count(templates.len())
12613 }
12614 S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
12615 S::WaitEntry { .. } => 1,
12616 S::BedPicker { .. } => self.re_bed_candidates().len(),
12617 S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
12618 S::FarmPlantSeed { seeds, .. } => seeds.len(),
12619 }
12620 }
12621
12622 pub fn re_sheet_index(&self) -> usize {
12624 use crate::worker_route_editor::RouteEditorSheet as S;
12625 let Some(ed) = self.state.worker_route_editor.as_ref() else {
12626 return 0;
12627 };
12628 match &ed.sheet {
12629 S::AddMenu { index }
12630 | S::WaypointMenu { index }
12631 | S::HarvestPicker { index, .. }
12632 | S::WithdrawContainers { index }
12633 | S::DepositContainers { index }
12634 | S::SellNpcs { index }
12635 | S::CraftBlueprint { index }
12636 | S::BedPicker { index }
12637 | S::FarmPlotPicker { index, .. }
12638 | S::FarmPlantSeed { index, .. }
12639 | S::WithdrawItems { index, .. }
12640 | S::DepositFilter { index, .. }
12641 | S::SellItem { index, .. } | S::MarketListItem { index, .. } => *index,
12642 _ => 0,
12643 }
12644 }
12645
12646 pub fn re_sheet_move(&mut self, delta: i32) {
12648 let count = self.re_sheet_row_count();
12649 if count == 0 {
12650 return;
12651 }
12652 let cur = self.re_sheet_index();
12653 let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
12654 self.re_sheet_set_index(next);
12655 }
12656
12657 pub fn re_sheet_page(&mut self, pages: i32) {
12658 let count = self.re_sheet_row_count();
12659 if count == 0 {
12660 return;
12661 }
12662 let cur = self.re_sheet_index();
12663 let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
12664 self.re_sheet_set_index(next);
12665 }
12666
12667 pub fn re_sheet_adjust(&mut self, delta: i32) {
12669 use crate::worker_route_editor::RouteEditorSheet as S;
12670 let index = self.re_sheet_index();
12671 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12672 return;
12673 };
12674 match &mut ed.sheet {
12675 S::WithdrawItems { lines, .. } => {
12676 if let Some(line) = lines.get_mut(index) {
12677 line.adjust_qty(delta);
12678 }
12679 }
12680 S::WaitEntry { ticks } => {
12681 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
12682 }
12683 _ => {}
12684 }
12685 }
12686
12687 pub fn re_sheet_back(&mut self) {
12688 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12689 return;
12690 };
12691 use crate::worker_route_editor::RouteEditorSheet as S;
12692 let was_editing = ed.editing_index.is_some();
12693 let from_top_picker = matches!(
12694 ed.sheet,
12695 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
12696 );
12697 ed.sheet_back();
12698 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
12699 self.state
12701 .push_log("Route: left edit sheet — press s to save current stops".to_string());
12702 }
12703 }
12704
12705 pub fn re_at_root_sheet(&self) -> bool {
12707 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12708 matches!(
12709 ed.sheet,
12710 crate::worker_route_editor::RouteEditorSheet::Stops
12711 )
12712 })
12713 }
12714
12715 pub fn re_open_add_menu(&mut self) {
12716 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12717 ed.open_add_menu();
12718 }
12719 }
12720
12721 pub fn re_open_bed_picker(&mut self) {
12722 let beds = self.re_bed_candidates();
12723 if beds.is_empty() {
12724 self.state
12725 .push_log("Route: place a camp bed first".to_string());
12726 return;
12727 }
12728 let current = self
12729 .state
12730 .worker_route_editor
12731 .as_ref()
12732 .and_then(|ed| ed.lodging_container_id.clone());
12733 let index = current
12734 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
12735 .unwrap_or(0);
12736 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
12737 }
12738
12739 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
12740 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12741 ed.open_sheet(sheet);
12742 }
12743 }
12744
12745 fn re_confirm_stop(&mut self, stop: crate::worker_route_editor::WorkerRouteStop, what: String) {
12747 let appended = self
12748 .state
12749 .worker_route_editor
12750 .as_mut()
12751 .is_some_and(|ed| ed.confirm_stop(stop));
12752 if appended {
12753 self.state.push_log(format!("Route: + {what}"));
12754 } else {
12755 self.state
12756 .push_log(format!("Route: {what} already in route — selected it"));
12757 }
12758 }
12759
12760 fn re_open_withdraw_items(&mut self, container_id: String) {
12761 use crate::worker_route_editor::{
12762 RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12763 };
12764 let contents = self.re_container_contents(&container_id);
12765 let existing = self
12769 .state
12770 .worker_route_editor
12771 .as_ref()
12772 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12773 .and_then(|stop| match stop {
12774 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
12775 _ => None,
12776 })
12777 .unwrap_or_default();
12778 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
12779 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12782 let _ = ed.retarget_withdraw_container(container_id.clone());
12783 }
12784 self.re_open_sheet(S::WithdrawItems {
12785 container_id,
12786 lines,
12787 index: 0,
12788 });
12789 }
12790
12791 fn re_withdraw_items_activate(&mut self, index: usize) {
12792 use crate::worker_route_editor::{
12793 RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12794 };
12795 enum Outcome {
12796 Cycled,
12797 Confirmed(String),
12798 Empty,
12799 }
12800 let outcome = {
12801 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12802 return;
12803 };
12804 let S::WithdrawItems {
12805 container_id,
12806 lines,
12807 index: sheet_index,
12808 } = &mut ed.sheet
12809 else {
12810 return;
12811 };
12812 *sheet_index = index;
12813 if index < lines.len() {
12814 lines[index].cycle();
12815 Outcome::Cycled
12816 } else {
12817 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
12818 if items.is_empty() {
12819 Outcome::Empty
12820 } else {
12821 let stop = WorkerRouteStop::WithdrawFrom {
12822 container_id: container_id.clone(),
12823 items,
12824 };
12825 let summary = stop.summary();
12826 ed.confirm_stop(stop);
12827 Outcome::Confirmed(summary)
12828 }
12829 }
12830 };
12831 match outcome {
12832 Outcome::Cycled => {}
12833 Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
12834 Outcome::Empty => self.state.push_log(
12835 "Route: pick at least one item (Space/Enter toggles All/qty)".to_string(),
12836 ),
12837 }
12838 }
12839
12840 fn re_open_deposit_filter(&mut self, container_id: String) {
12841 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12842 let existing_filter = self
12844 .state
12845 .worker_route_editor
12846 .as_ref()
12847 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12848 .and_then(|stop| match stop {
12849 WorkerRouteStop::DepositAt { filter, .. } => {
12850 Some(filter.clone().unwrap_or_default())
12851 }
12852 _ => None,
12853 });
12854 let mut candidates = self.re_template_candidates();
12855 if let Some(ref chosen) = existing_filter {
12856 for t in chosen {
12857 if !candidates.iter().any(|c| c == t) {
12858 candidates.push(t.clone());
12859 }
12860 }
12861 candidates.sort();
12862 candidates.dedup();
12863 }
12864 let rows: Vec<(String, bool)> = match existing_filter {
12865 Some(chosen) => candidates
12866 .iter()
12867 .map(|t| (t.clone(), chosen.contains(t)))
12868 .collect(),
12869 None => candidates.into_iter().map(|t| (t, false)).collect(),
12870 };
12871 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12872 let _ = ed.retarget_deposit_container(container_id.clone());
12873 }
12874 self.re_open_sheet(S::DepositFilter {
12875 container_id,
12876 rows,
12877 index: 0,
12878 });
12879 }
12880
12881 fn re_deposit_filter_activate(&mut self, index: usize) {
12882 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12883 let mut confirmed: Option<String> = None;
12884 {
12885 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12886 return;
12887 };
12888 let S::DepositFilter {
12889 container_id,
12890 rows,
12891 index: sheet_index,
12892 } = &mut ed.sheet
12893 else {
12894 return;
12895 };
12896 *sheet_index = index;
12897 if index < rows.len() {
12898 rows[index].1 = !rows[index].1;
12899 } else {
12900 let chosen: Vec<String> = rows
12902 .iter()
12903 .filter(|(_, on)| *on)
12904 .map(|(t, _)| t.clone())
12905 .collect();
12906 let filter = if chosen.is_empty() {
12907 None
12908 } else {
12909 Some(chosen)
12910 };
12911 let stop = WorkerRouteStop::DepositAt {
12912 container_id: container_id.clone(),
12913 filter,
12914 };
12915 confirmed = Some(stop.summary());
12916 ed.confirm_stop(stop);
12917 }
12918 }
12919 if let Some(what) = confirmed {
12920 self.state.push_log(format!("Route: + {what}"));
12921 }
12922 }
12923
12924 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
12925 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12926 let (pre_npc, pre_template, pre_all) = self
12928 .state
12929 .worker_route_editor
12930 .as_ref()
12931 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12932 .and_then(|stop| match stop {
12933 WorkerRouteStop::TradeWith {
12934 npc_id,
12935 template,
12936 sell_all,
12937 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
12938 _ => None,
12939 })
12940 .unwrap_or((None, None, true));
12941 let npc_id = npc_id.or(pre_npc);
12942 let mut templates = crate::worker_route_editor::sellable_route_item_template_candidates(
12943 &self.re_template_candidates(),
12944 &self.state.npcs,
12945 npc_id.as_deref(),
12946 );
12947 if let Some(template) = pre_template.as_ref() {
12950 if !templates.iter().any(|candidate| candidate == template) {
12951 templates.push(template.clone());
12952 templates.sort();
12953 }
12954 }
12955 if templates.is_empty() {
12956 let msg = crate::worker_route_editor::sell_merchant_empty_reason(
12957 npc_id.as_deref(),
12958 &self.state.npcs,
12959 &self.re_template_candidates(),
12960 );
12961 self.state.push_log(msg);
12962 return;
12963 }
12964 let mut picked = std::collections::BTreeSet::new();
12965 if let Some(t) = pre_template {
12966 picked.insert(t);
12967 }
12968 self.re_open_sheet(S::SellItem {
12969 npc_id,
12970 templates,
12971 index: if picked.is_empty() {
12972 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
12973 } else {
12974 2
12975 },
12976 sell_all: pre_all,
12977 picked,
12978 });
12979 }
12980
12981 fn re_sell_item_activate(&mut self, index: usize) {
12982 use crate::worker_route_editor::{
12983 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12984 };
12985 let mut batch_log: Option<String> = None;
12986 {
12987 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12988 return;
12989 };
12990 let S::SellItem {
12991 npc_id,
12992 templates,
12993 index: sheet_index,
12994 sell_all,
12995 picked,
12996 } = &mut ed.sheet
12997 else {
12998 return;
12999 };
13000 *sheet_index = index;
13001 if index == ROUTE_PICKER_DONE_ROW {
13002 if picked.is_empty() {
13003 batch_log =
13004 Some("Route: pick at least one item (Space toggles, Done confirms)".into());
13005 } else {
13006 let picks: Vec<String> = picked.iter().cloned().collect();
13007 let npc = npc_id.clone();
13008 let all = *sell_all;
13009 let added = ed.confirm_trade_picks(npc, &picks, all);
13010 batch_log = Some(format!("Route: + {added} sell stop(s)"));
13011 }
13012 } else if index == SELL_ITEM_TOGGLE_ROW {
13013 *sell_all = !*sell_all;
13014 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13015 let sellable = crate::worker_route_editor::sellable_route_item_template_candidates(
13016 std::slice::from_ref(template),
13017 &self.state.npcs,
13018 npc_id.as_deref(),
13019 )
13020 .iter()
13021 .any(|candidate| candidate == template);
13022 if !sellable && !picked.contains(template) {
13023 return;
13024 }
13025 if picked.contains(template) {
13026 picked.remove(template);
13027 } else {
13028 picked.insert(template.clone());
13029 }
13030 }
13031 }
13032 if let Some(msg) = batch_log {
13033 self.state.push_log(msg);
13034 }
13035 }
13036
13037 fn re_open_market_list_item(&mut self) {
13038 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13039 let (pre_hall, pre_template, pre_all) = self
13040 .state
13041 .worker_route_editor
13042 .as_ref()
13043 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13044 .and_then(|stop| match stop {
13045 WorkerRouteStop::ListOnMarket {
13046 hall_id,
13047 template,
13048 list_all,
13049 } => Some((hall_id.clone(), Some(template.clone()), *list_all)),
13050 _ => None,
13051 })
13052 .unwrap_or((None, None, true));
13053 let mut templates = self.re_template_candidates();
13054 templates.sort_by_key(|t| {
13057 std::cmp::Reverse(self.state.item_base_value_copper_hint(t).unwrap_or(0))
13058 });
13059 if let Some(template) = pre_template.as_ref() {
13060 if !templates.iter().any(|c| c == template) {
13061 templates.push(template.clone());
13062 }
13063 }
13064 if templates.is_empty() {
13065 self.state.push_log("Route: no item templates available for market list".to_string());
13066 return;
13067 }
13068 let mut picked = std::collections::BTreeSet::new();
13069 if let Some(t) = pre_template {
13070 picked.insert(t);
13071 }
13072 self.re_open_sheet(S::MarketListItem {
13073 hall_id: pre_hall,
13074 templates,
13075 index: if picked.is_empty() {
13076 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
13077 } else {
13078 2
13079 },
13080 list_all: pre_all,
13081 picked,
13082 });
13083 }
13084
13085 fn re_market_list_item_activate(&mut self, index: usize) {
13086 use crate::worker_route_editor::{
13087 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
13088 };
13089 let mut batch_log: Option<String> = None;
13090 {
13091 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13092 return;
13093 };
13094 let S::MarketListItem {
13095 hall_id,
13096 templates,
13097 index: sheet_index,
13098 list_all,
13099 picked,
13100 } = &mut ed.sheet
13101 else {
13102 return;
13103 };
13104 *sheet_index = index;
13105 if index == ROUTE_PICKER_DONE_ROW {
13106 if picked.is_empty() {
13107 batch_log = Some(
13108 "Route: pick at least one item (Space toggles, Done confirms)".into(),
13109 );
13110 } else {
13111 let picks: Vec<String> = picked.iter().cloned().collect();
13112 let hall = hall_id.clone();
13113 let all = *list_all;
13114 let added = ed.confirm_market_list_picks(hall, &picks, all);
13115 batch_log = Some(format!("Route: + {added} market-list stop(s)"));
13116 }
13117 } else if index == SELL_ITEM_TOGGLE_ROW {
13118 *list_all = !*list_all;
13119 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13120 if picked.contains(template) {
13121 picked.remove(template);
13122 } else {
13123 picked.insert(template.clone());
13124 }
13125 }
13126 }
13127 if let Some(msg) = batch_log {
13128 self.state.push_log(msg);
13129 }
13130 }
13131
13132 pub fn re_edit_selected_stop(&mut self) {
13134 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13135 let Some(stop) = self
13136 .state
13137 .worker_route_editor
13138 .as_ref()
13139 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
13140 else {
13141 self.state
13142 .push_log("Route: no stop selected — press a to add one".to_string());
13143 return;
13144 };
13145 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13146 ed.begin_edit_selected();
13147 }
13148 match stop {
13149 WorkerRouteStop::Waypoint { .. } => {
13150 self.re_open_sheet(S::WaypointMenu { index: 0 });
13151 }
13152 WorkerRouteStop::HarvestNode { node_id } => {
13153 let nodes = self.state.route_editor_node_candidates();
13154 if nodes.is_empty() {
13155 self.re_cancel_edit();
13156 self.state
13157 .push_log("Route: no harvestable nodes visible to retarget".to_string());
13158 } else {
13159 let mut picked = std::collections::BTreeSet::new();
13160 picked.insert(node_id.clone());
13161 let index = nodes
13162 .iter()
13163 .position(|n| n.id == node_id)
13164 .map(|i| i + 1)
13165 .unwrap_or(1);
13166 self.re_open_harvest_picker(index, picked);
13167 }
13168 }
13169 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
13170 let containers = self.re_container_candidates();
13173 if containers.is_empty() {
13174 self.re_cancel_edit();
13175 self.state
13176 .push_log("Route: place a storage chest first".to_string());
13177 } else {
13178 let index = containers
13179 .iter()
13180 .position(|c| c.id == container_id)
13181 .unwrap_or(0);
13182 self.re_open_sheet(S::WithdrawContainers { index });
13183 }
13184 }
13185 WorkerRouteStop::DepositAt { container_id, .. } => {
13186 let containers = self.re_container_candidates();
13187 if containers.is_empty() {
13188 self.re_cancel_edit();
13189 self.state
13190 .push_log("Route: place a storage chest first".to_string());
13191 } else {
13192 let index = containers
13193 .iter()
13194 .position(|c| c.id == container_id)
13195 .unwrap_or(0);
13196 self.re_open_sheet(S::DepositContainers { index });
13197 }
13198 }
13199 WorkerRouteStop::TradeWith { npc_id, .. } => {
13200 let npcs = self.re_npc_candidates();
13201 let index = npc_id
13203 .as_ref()
13204 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
13205 .unwrap_or(0);
13206 self.re_open_sheet(S::SellNpcs { index });
13207 }
13208 WorkerRouteStop::ListOnMarket { .. } => {
13209 self.re_open_market_list_item();
13210 }
13211 WorkerRouteStop::CraftAt { blueprint, .. } => {
13212 let bps = self.re_blueprint_ids();
13213 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
13214 if bps.is_empty() {
13215 self.re_cancel_edit();
13216 self.state
13217 .push_log("Route: no known blueprints to retarget".to_string());
13218 } else {
13219 self.re_open_sheet(S::CraftBlueprint { index });
13220 }
13221 }
13222 WorkerRouteStop::CultivatePlot { .. } => {
13223 self.re_open_farm_plot_picker(
13224 crate::worker_route_editor::FarmPlotAction::Cultivate,
13225 );
13226 }
13227 WorkerRouteStop::PlantPlot { .. } => {
13228 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
13229 }
13230 WorkerRouteStop::HarvestPlot { .. } => {
13231 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
13232 }
13233 WorkerRouteStop::RestIfNeeded => {
13234 self.re_cancel_edit();
13235 self.state
13236 .push_log("Route: rest has no settings (change the bed with l)".to_string());
13237 }
13238 WorkerRouteStop::Wait { wait_ticks } => {
13239 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
13240 }
13241 }
13242 }
13243
13244 fn re_cancel_edit(&mut self) {
13245 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13246 ed.editing_index = None;
13247 }
13248 }
13249
13250 pub fn worker_route_editor_ui_click(
13253 &mut self,
13254 click: crate::worker_route_editor::RouteEditorClick,
13255 ) {
13256 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
13257 match click {
13258 RouteEditorClick::SelectStop(i) => {
13259 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13260 ed.sheet = S::Stops;
13261 ed.select_stop(i);
13262 }
13263 }
13264 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
13265 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
13266 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
13267 }
13268 }
13269
13270 pub fn re_sheet_row_activate(&mut self, row: usize) {
13272 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13273 let Some(sheet) = self
13274 .state
13275 .worker_route_editor
13276 .as_ref()
13277 .map(|ed| ed.sheet.clone())
13278 else {
13279 return;
13280 };
13281 match sheet {
13282 S::Stops => {
13283 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13284 ed.select_stop(row);
13285 }
13286 }
13287 S::AddMenu { .. } => match row {
13288 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
13289 1 => {
13290 if self.re_node_candidates().is_empty() {
13291 self.state.push_log(
13292 "Route: no harvestable nodes visible in this region".to_string(),
13293 );
13294 } else {
13295 self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
13296 }
13297 }
13298 2 | 3 => {
13299 if self.re_container_candidates().is_empty() {
13300 self.state
13301 .push_log("Route: place a storage chest first".to_string());
13302 } else if row == 2 {
13303 self.re_open_sheet(S::WithdrawContainers { index: 0 });
13304 } else {
13305 self.re_open_sheet(S::DepositContainers { index: 0 });
13306 }
13307 }
13308 4 => {
13309 if self.re_template_candidates().is_empty() {
13310 self.state.push_log(
13311 "Route: no item templates available — learn a craft recipe or place a harvest node first"
13312 .to_string(),
13313 );
13314 } else {
13315 self.re_open_sheet(S::SellNpcs { index: 0 });
13316 }
13317 }
13318 5 => {
13319 if self.re_template_candidates().is_empty() {
13320 self.state.push_log(
13321 "Route: no item templates available — learn a craft recipe or place a harvest node first"
13322 .to_string(),
13323 );
13324 } else {
13325 self.re_open_market_list_item();
13326 }
13327 }
13328 6 => {
13329 if self.re_blueprint_ids().is_empty() {
13330 self.state.push_log(
13331 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
13332 .to_string(),
13333 );
13334 } else {
13335 self.re_open_sheet(S::CraftBlueprint { index: 0 });
13336 }
13337 }
13338 7 => self.re_confirm_stop(
13339 WorkerRouteStop::RestIfNeeded,
13340 "rest at lodging (if needed)".into(),
13341 ),
13342 8 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
13343 9 => self.re_open_farm_plot_picker(
13344 crate::worker_route_editor::FarmPlotAction::Cultivate,
13345 ),
13346 10 => {
13347 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant)
13348 }
13349 11 => self
13350 .re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
13351 _ => {}
13352 },
13353 S::WaypointMenu { .. } => match row {
13354 0 => {
13355 let (x, y, z) = self.state.player_position_with_z();
13356 let stop = WorkerRouteStop::Waypoint { x, y, z };
13357 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13358 }
13359 1 => {
13360 self.re_open_sheet(S::WaypointMapPick);
13361 self.state.push_log(
13362 "Route: click the map to place the waypoint (Esc to finish)".to_string(),
13363 );
13364 }
13365 _ => {}
13366 },
13367 S::HarvestPicker { .. } => {
13368 let mut log: Option<String> = None;
13369 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13370 let S::HarvestPicker {
13371 index: sheet_index,
13372 picked,
13373 nodes,
13374 } = &mut ed.sheet
13375 else {
13376 return;
13377 };
13378 *sheet_index = row;
13379 if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
13380 if picked.is_empty() {
13381 log = Some(
13382 "Route: pick at least one node (Space toggles, Done confirms)"
13383 .into(),
13384 );
13385 } else {
13386 let ids: Vec<String> = picked.iter().cloned().collect();
13387 let added = ed.confirm_harvest_picks(&ids);
13388 log = Some(format!("Route: + {added} harvest stop(s)"));
13389 }
13390 } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
13391 if picked.contains(&n.id) {
13392 picked.remove(&n.id);
13393 } else {
13394 picked.insert(n.id.clone());
13395 }
13396 }
13397 }
13398 if let Some(msg) = log {
13399 self.state.push_log(msg);
13400 }
13401 }
13402 S::WithdrawContainers { .. } => {
13403 let containers = self.re_container_candidates();
13404 if let Some(c) = containers.get(row) {
13405 let id = c.id.clone();
13406 self.re_open_withdraw_items(id);
13407 }
13408 }
13409 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
13410 S::DepositContainers { .. } => {
13411 let containers = self.re_container_candidates();
13412 if let Some(c) = containers.get(row) {
13413 let id = c.id.clone();
13414 self.re_open_deposit_filter(id);
13415 }
13416 }
13417 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
13418 S::SellNpcs { .. } => {
13419 let templates = self.re_template_candidates();
13420 let npcs = self.re_npc_candidates();
13421 if row == 0 {
13422 if !crate::worker_route_editor::any_trade_npc_buys_route_item(
13423 &self.state.npcs,
13424 &templates,
13425 ) {
13426 self.state.push_log(
13427 crate::worker_route_editor::sell_merchant_empty_reason(
13428 None,
13429 &self.state.npcs,
13430 &templates,
13431 ),
13432 );
13433 return;
13434 }
13435 self.re_open_sell_item(None);
13436 return;
13437 }
13438 let Some(n) = npcs.get(row - 1) else {
13439 return;
13440 };
13441 if !n.buys_route_item {
13442 self.state.push_log(
13443 crate::worker_route_editor::sell_merchant_empty_reason(
13444 Some(n.id.as_str()),
13445 &self.state.npcs,
13446 &templates,
13447 ),
13448 );
13449 return;
13450 }
13451 self.re_open_sell_item(Some(n.id.clone()));
13452 }
13453 S::SellItem { .. } => self.re_sell_item_activate(row),
13454 S::MarketListItem { .. } => self.re_market_list_item_activate(row),
13455 S::CraftBlueprint { .. } => {
13456 let bps = self.re_blueprint_ids();
13457 if let Some(bp) = bps.get(row) {
13458 let stop = WorkerRouteStop::CraftAt {
13459 device: "hand".into(),
13460 blueprint: bp.clone(),
13461 qty: None,
13462 };
13463 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
13464 }
13465 }
13466 S::WaitEntry { ticks } => {
13467 let stop = WorkerRouteStop::Wait { wait_ticks: ticks };
13468 self.re_confirm_stop(stop, format!("wait {ticks}t"));
13469 }
13470 S::BedPicker { .. } => {
13471 let beds = self.re_bed_candidates();
13472 if let Some((id, name)) = beds.get(row) {
13473 let (id, name) = (id.clone(), name.clone());
13474 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13475 ed.lodging_container_id = Some(id.clone());
13476 ed.sheet = S::Stops;
13477 }
13478 self.state
13479 .push_log(format!("Route: rest bed set to {name}"));
13480 }
13481 }
13482 S::FarmPlotPicker { action, .. } => {
13483 let plots = self.re_farm_plot_candidates();
13484 let Some(plot) = plots.get(row).cloned() else {
13485 return;
13486 };
13487 match action {
13488 crate::worker_route_editor::FarmPlotAction::Cultivate => {
13489 let label = plot_route_label(&plot);
13490 self.re_confirm_stop(
13491 WorkerRouteStop::CultivatePlot {
13492 plot_id: plot.plot_id,
13493 },
13494 format!("cultivate {label}"),
13495 );
13496 }
13497 crate::worker_route_editor::FarmPlotAction::Harvest => {
13498 let label = plot_route_label(&plot);
13499 self.re_confirm_stop(
13500 WorkerRouteStop::HarvestPlot {
13501 plot_id: plot.plot_id,
13502 },
13503 format!("harvest {label}"),
13504 );
13505 }
13506 crate::worker_route_editor::FarmPlotAction::Plant => {
13507 let seeds = self.re_farm_seed_candidates();
13508 if seeds.is_empty() {
13509 self.state.push_log(
13510 "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
13511 );
13512 return;
13513 }
13514 self.re_open_sheet(S::FarmPlantSeed {
13515 plot_id: plot.plot_id,
13516 seeds,
13517 index: 0,
13518 });
13519 }
13520 }
13521 }
13522 S::FarmPlantSeed { plot_id, seeds, .. } => {
13523 if let Some(seed) = seeds.get(row).cloned() {
13524 self.re_confirm_stop(
13525 WorkerRouteStop::PlantPlot {
13526 plot_id,
13527 seed_template: seed.clone(),
13528 },
13529 format!("plant {seed}"),
13530 );
13531 }
13532 }
13533 S::WaypointMapPick => {}
13534 }
13535 }
13536
13537 fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
13538 use crate::worker_route_editor::RouteEditorSheet as S;
13539 if self.re_farm_plot_candidates().is_empty() {
13540 self.state
13541 .push_log("Route: no farmable plots visible — claim land or get farm access first");
13542 return;
13543 }
13544 self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
13545 }
13546
13547 fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
13548 self.state
13549 .property_plots
13550 .iter()
13551 .filter(|p| p.is_mine || p.may_farm)
13552 .cloned()
13553 .collect()
13554 }
13555
13556 fn re_farm_seed_candidates(&self) -> Vec<String> {
13560 let mut set = std::collections::BTreeSet::new();
13561 let looks_like_seed = |id: &str, catalog: &std::collections::HashMap<String, ItemCatalogEntryView>| {
13562 catalog.get(id).is_some_and(|e| e.is_farm_seed()) || id.ends_with("_seed")
13563 };
13564 for (id, _, _) in self.state.farm_seed_entries() {
13565 set.insert(id);
13566 }
13567 for c in &self.state.placed_containers {
13568 let mine = match (self.state.character_id, c.owner_character_id) {
13569 (Some(a), Some(b)) => a == b,
13570 _ => false,
13571 };
13572 if !mine {
13573 continue;
13574 }
13575 for s in &c.contents {
13576 if s.quantity > 0
13577 && (s.props.contains_key("seed_for")
13578 || looks_like_seed(&s.template_id, &self.state.item_catalog))
13579 {
13580 set.insert(s.template_id.clone());
13581 }
13582 }
13583 }
13584 if let Some(ed) = self.state.worker_route_editor.as_ref() {
13585 for stop in &ed.stops {
13586 if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } =
13587 stop
13588 {
13589 for it in items {
13590 if looks_like_seed(&it.template, &self.state.item_catalog) {
13591 set.insert(it.template.clone());
13592 }
13593 }
13594 }
13595 if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
13596 seed_template,
13597 ..
13598 } = stop
13599 {
13600 if !seed_template.is_empty() {
13601 set.insert(seed_template.clone());
13602 }
13603 }
13604 }
13605 }
13606 for (id, entry) in &self.state.item_catalog {
13607 if entry.is_farm_seed() {
13608 set.insert(id.clone());
13609 }
13610 }
13611 set.into_iter().collect()
13612 }
13613
13614 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
13621 use crate::worker_route_editor as wre;
13622 use wre::RouteEditorSheet as S;
13623 if self.state.worker_route_editor.is_none() {
13624 return;
13625 }
13626 let sheet = self
13627 .state
13628 .worker_route_editor
13629 .as_ref()
13630 .map(|ed| ed.sheet.clone())
13631 .unwrap_or(S::Stops);
13632 match sheet {
13633 S::WaypointMapPick => {
13634 let (_, _, z) = self.state.player_position_with_z();
13635 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
13636 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13637 let editing = self
13639 .state
13640 .worker_route_editor
13641 .as_ref()
13642 .is_some_and(|ed| ed.editing_index.is_some());
13643 if !editing {
13644 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13645 ed.sheet = S::WaypointMapPick;
13646 }
13647 }
13648 }
13649 S::HarvestPicker { .. } => {
13650 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13651 let mut log: Option<String> = None;
13652 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13653 let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
13654 return;
13655 };
13656 let selected = if picked.contains(&node.id) {
13657 picked.remove(&node.id);
13658 false
13659 } else {
13660 picked.insert(node.id.clone());
13661 true
13662 };
13663 log = Some(format!(
13664 "Route: {} {}",
13665 if selected { "selected" } else { "deselected" },
13666 resource_node_route_label(node)
13667 ));
13668 }
13669 if let Some(msg) = log {
13670 self.state.push_log(msg);
13671 }
13672 }
13673 }
13674 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
13675 let inside = self.state.effective_inside_building();
13677 if let Some(cid) = wre::pick_storage_container_at(
13678 &self.state.placed_containers,
13679 self.state.character_id,
13680 x,
13681 y,
13682 inside.as_deref(),
13683 ) {
13684 self.re_open_withdraw_items(cid);
13685 }
13686 }
13687 S::DepositContainers { .. } | S::DepositFilter { .. } => {
13688 let inside = self.state.effective_inside_building();
13689 if let Some(cid) = wre::pick_storage_container_at(
13690 &self.state.placed_containers,
13691 self.state.character_id,
13692 x,
13693 y,
13694 inside.as_deref(),
13695 ) {
13696 self.re_open_deposit_filter(cid);
13697 }
13698 }
13699 S::SellNpcs { .. } => {
13700 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13701 self.re_open_sell_item(Some(npc_id));
13702 }
13703 }
13704 S::SellItem { .. } => {
13705 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13706 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13707 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
13708 *slot = Some(npc_id.clone());
13709 }
13710 }
13711 self.state
13712 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13713 }
13714 }
13715 _ => self.worker_route_editor_quick_add_click(x, y),
13717 }
13718 }
13719
13720 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
13724 use crate::worker_route_editor as wre;
13725 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
13726 let dx = ax - bx;
13727 let dy = ay - by;
13728 (dx * dx + dy * dy).sqrt()
13729 };
13730
13731 let selected_stop_kind = self
13734 .state
13735 .worker_route_editor
13736 .as_ref()
13737 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
13738 .map(|s| match s {
13739 wre::WorkerRouteStop::TradeWith { .. } => 1,
13740 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
13741 _ => 0,
13742 })
13743 .unwrap_or(0);
13744 if selected_stop_kind == 1 {
13745 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13746 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13747 ed.set_selected_trade_npc(npc_id.clone());
13748 }
13749 self.state
13750 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13751 return;
13752 }
13753 }
13754 if selected_stop_kind == 2 {
13755 let inside = self.state.effective_inside_building();
13756 if let Some(cid) = wre::pick_storage_container_at(
13757 &self.state.placed_containers,
13758 self.state.character_id,
13759 x,
13760 y,
13761 inside.as_deref(),
13762 ) {
13763 let name = self
13764 .state
13765 .placed_containers
13766 .iter()
13767 .find(|c| c.id == cid)
13768 .map(|c| c.display_name.clone())
13769 .unwrap_or_else(|| "container".into());
13770 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13771 ed.set_selected_withdraw_container(cid.clone());
13772 }
13773 self.state
13774 .push_log(format!("Route: withdraw source → {name}"));
13775 return;
13776 }
13777 }
13778
13779 enum Target {
13782 Bed(String),
13783 Container(String),
13784 Npc(String, String),
13785 Node(String, String),
13786 }
13787 let mut best: Option<(f32, u8, Target)> = None;
13788 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
13789 let better = match best {
13790 None => true,
13791 Some((bd, brank, _)) => {
13792 d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank)
13793 }
13794 };
13795 if better {
13796 *best = Some((d, rank, t));
13797 }
13798 };
13799 let inside = self.state.effective_inside_building();
13800 if let Some(bed_id) = wre::pick_lodging_container_at(
13801 &self.state.placed_containers,
13802 self.state.character_id,
13803 x,
13804 y,
13805 inside.as_deref(),
13806 ) {
13807 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
13808 let already_bed =
13811 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
13812 ed.lodging_container_id.as_deref() == Some(bed_id.as_str())
13813 });
13814 if already_bed {
13815 consider(
13816 dist(x, y, c.x, c.y),
13817 1,
13818 Target::Container(bed_id),
13819 &mut best,
13820 );
13821 } else {
13822 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
13823 }
13824 }
13825 }
13826 if let Some(cid) = wre::pick_storage_container_at(
13827 &self.state.placed_containers,
13828 self.state.character_id,
13829 x,
13830 y,
13831 inside.as_deref(),
13832 ) {
13833 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
13834 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
13835 }
13836 }
13837 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13838 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
13839 consider(
13840 dist(x, y, n.x, n.y),
13841 2,
13842 Target::Npc(npc_id, label),
13843 &mut best,
13844 );
13845 }
13846 }
13847 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13848 let d = dist(x, y, node.x, node.y);
13849 let label = resource_node_route_label(node);
13850 consider(d, 3, Target::Node(node.id.clone(), label), &mut best);
13851 }
13852
13853 match best.map(|(_, _, t)| t) {
13854 Some(Target::Bed(bed_id)) => {
13855 let name = self
13856 .state
13857 .placed_containers
13858 .iter()
13859 .find(|c| c.id == bed_id)
13860 .map(|c| c.display_name.clone())
13861 .unwrap_or_else(|| "camp bed".into());
13862 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13863 ed.lodging_container_id = Some(bed_id.clone());
13864 }
13865 self.state
13866 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
13867 }
13868 Some(Target::Container(cid)) => {
13869 let name = self
13870 .state
13871 .placed_containers
13872 .iter()
13873 .find(|c| c.id == cid)
13874 .map(|c| c.display_name.clone())
13875 .unwrap_or_else(|| "container".into());
13876 let added = self
13877 .state
13878 .worker_route_editor
13879 .as_mut()
13880 .is_some_and(|ed| ed.append_deposit_at(&cid));
13881 if added {
13882 self.state
13883 .push_log(format!("Route: + deposit at {name} ({cid})"));
13884 } else {
13885 self.state.push_log(format!(
13886 "Route: {name} already in route — selected it (d to remove)"
13887 ));
13888 }
13889 }
13890 Some(Target::Npc(npc_id, label)) => {
13891 let template = self.re_template_candidates().into_iter().next();
13894 let Some(template) = template else {
13895 self.state.push_log(
13896 "Route: no items in your storage to sell — stock a chest first".to_string(),
13897 );
13898 return;
13899 };
13900 let added = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
13901 ed.append_trade_with(template.clone(), Some(npc_id.clone()), true)
13902 });
13903 if added {
13904 self.state
13905 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
13906 } else {
13907 self.state.push_log(format!(
13908 "Route: {label} already sells {template} — selected it (d to remove)"
13909 ));
13910 }
13911 }
13912 Some(Target::Node(id, label)) => {
13913 let added = self
13914 .state
13915 .worker_route_editor
13916 .as_mut()
13917 .is_some_and(|ed| ed.append_harvest_node(&id));
13918 if added {
13919 self.state
13920 .push_log(format!("Route: + harvest node {label}"));
13921 } else {
13922 self.state.push_log(format!(
13923 "Route: {label} already in route — selected it (d to remove)"
13924 ));
13925 }
13926 }
13927 None => {}
13928 }
13929 }
13930
13931 pub fn worker_route_editor_select(&mut self, delta: i32) {
13932 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13933 return;
13934 };
13935 if ed.stops.is_empty() {
13936 return;
13937 }
13938 let n = ed.stops.len() as i32;
13939 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
13940 ed.selected_stop_index = next;
13941 }
13942
13943 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
13944 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13945 return;
13946 };
13947 if delta < 0 {
13948 ed.move_selected_up();
13949 } else if delta > 0 {
13950 ed.move_selected_down();
13951 }
13952 }
13953
13954 pub fn worker_route_editor_delete_selected(&mut self) {
13955 let removed = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
13956 let before = ed.stop_count();
13957 ed.remove_selected_stop();
13958 ed.stop_count() < before
13959 });
13960 if removed {
13961 self.state.push_log("Route: removed selected stop");
13962 }
13963 }
13964
13965 pub fn worker_route_editor_clear_stops(&mut self) {
13968 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13969 return;
13970 };
13971 if ed.stops.is_empty() {
13972 self.state
13973 .push_log("Route: already empty — s saves an idle worker".to_string());
13974 return;
13975 }
13976 ed.stops.clear();
13977 ed.selected_stop_index = 0;
13978 self.state.push_log(
13979 "Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string(),
13980 );
13981 }
13982
13983 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
13984 if self.state.pending_worker_job_ack.is_some() {
13985 anyhow::bail!("route save still pending — wait for server ack");
13986 }
13987 let Some(ed) = self.state.worker_route_editor.clone() else {
13988 anyhow::bail!("route editor not open");
13989 };
13990 let (job_yaml, idle) = if ed.stops.is_empty() {
13993 (ed.build_idle_job_yaml(), true)
13994 } else {
13995 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
13996 };
13997 let worker_id = ed.worker_instance_id.clone();
13998 let route_view = if idle { None } else { Some(ed.to_route_view()) };
13999 let mode = if idle {
14000 flatland_protocol::WorkerModeView::Idle
14001 } else {
14002 flatland_protocol::WorkerModeView::JobLoop
14003 };
14004 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
14005 .state
14006 .hired_workers
14007 .iter()
14008 .find(|w| w.instance_id == worker_id)
14009 .map(|w| {
14010 (
14011 w.route.clone(),
14012 w.mode,
14013 w.step_label.clone(),
14014 w.last_error.clone(),
14015 )
14016 })
14017 .unwrap_or((
14018 None,
14019 flatland_protocol::WorkerModeView::Idle,
14020 String::new(),
14021 None,
14022 ));
14023 self.seq += 1;
14024 let seq = self.seq;
14025 self.session
14026 .submit_intent(Intent::SetWorkerJob {
14027 entity_id: self.state.entity_id,
14028 worker_instance_id: worker_id.clone(),
14029 job_yaml,
14030 seq,
14031 })
14032 .await?;
14033 self.state.intents_sent += 1;
14034 if let Some(w) = self
14035 .state
14036 .hired_workers
14037 .iter_mut()
14038 .find(|w| w.instance_id == worker_id)
14039 {
14040 w.route = route_view;
14041 w.mode = mode;
14042 w.last_error = None;
14043 if idle {
14044 w.step_label.clear();
14045 w.route_stop_index = None;
14046 }
14047 }
14048 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
14049 seq,
14050 worker_instance_id: worker_id,
14051 worker_label: ed.worker_label.clone(),
14052 idle,
14053 stop_count: ed.stops.len(),
14054 prev_route,
14055 prev_mode,
14056 prev_step_label,
14057 prev_last_error,
14058 });
14059 self.state.push_log(format!(
14060 "Route: saving for {}… (waiting for server)",
14061 ed.worker_label
14062 ));
14063 Ok(())
14065 }
14066 pub fn quest_menu_move(&mut self, delta: i32) {
14067 let n = self.state.active_quest_entries().len();
14068 if n == 0 {
14069 return;
14070 }
14071 let idx = self.state.quest_menu_index as i32;
14072 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
14073 }
14074
14075 pub fn quest_menu_page(&mut self, pages: i32) {
14076 let n = self.state.active_quest_entries().len();
14077 self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
14078 }
14079
14080 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
14081 let Some(offer) = self.state.selected_quest_offer().cloned() else {
14082 anyhow::bail!("no quest offer");
14083 };
14084 self.seq += 1;
14085 let seq = self.seq;
14086 self.session
14087 .submit_intent(Intent::AcceptQuest {
14088 entity_id: self.state.entity_id,
14089 quest_id: offer.quest_id,
14090 seq,
14091 })
14092 .await?;
14093 self.state.intents_sent += 1;
14094 Ok(())
14095 }
14096
14097 pub fn quest_offer_move(&mut self, delta: i32) {
14098 self.state.move_quest_offer_selection(delta);
14099 }
14100
14101 pub fn quest_offer_decline(&mut self) {
14102 self.state.clear_quest_offers();
14103 if !self.state.show_npc_chat
14104 && !self.state.show_shop_menu
14105 && self.state.npc_verb_target.is_some()
14106 {
14107 self.state.show_npc_verb_menu = true;
14108 }
14109 }
14110
14111 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
14112 if !self.state.show_quest_menu {
14113 return Ok(());
14114 }
14115 let active: Vec<_> = self
14116 .state
14117 .active_quest_entries()
14118 .into_iter()
14119 .cloned()
14120 .collect();
14121 let Some(entry) = active.get(self.state.quest_menu_index) else {
14122 return Ok(());
14123 };
14124 if self.state.quest_withdraw_confirm {
14125 if !entry.can_withdraw {
14126 anyhow::bail!("quest cannot be withdrawn");
14127 }
14128 self.seq += 1;
14129 let seq = self.seq;
14130 self.session
14131 .submit_intent(Intent::WithdrawQuest {
14132 entity_id: self.state.entity_id,
14133 quest_id: entry.quest_id.clone(),
14134 seq,
14135 })
14136 .await?;
14137 self.state.intents_sent += 1;
14138 self.state.quest_withdraw_confirm = false;
14139 return Ok(());
14140 }
14141 self.seq += 1;
14142 let seq = self.seq;
14143 self.session
14144 .submit_intent(Intent::TrackQuest {
14145 entity_id: self.state.entity_id,
14146 quest_id: entry.quest_id.clone(),
14147 seq,
14148 })
14149 .await?;
14150 self.state.intents_sent += 1;
14151 Ok(())
14152 }
14153
14154 pub fn quest_request_withdraw(&mut self) {
14155 if self.state.show_quest_menu {
14156 self.state.quest_withdraw_confirm = true;
14157 }
14158 }
14159
14160 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
14161 if !self.state.is_alive() {
14162 anyhow::bail!("you are dead");
14163 }
14164 let Some(catalog) = self.state.shop_catalog.clone() else {
14165 anyhow::bail!("no shop open");
14166 };
14167 self.seq += 1;
14168 let seq = self.seq;
14169 match self.state.shop_tab {
14170 ShopTab::Buy => {
14171 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
14172 anyhow::bail!("nothing selected");
14173 };
14174 if offer.already_owned {
14175 anyhow::bail!("already owned");
14176 }
14177 self.session
14178 .submit_intent(Intent::ShopBuy {
14179 entity_id: self.state.entity_id,
14180 npc_id: catalog.npc_id.clone(),
14181 offer_id: offer.offer_id.clone(),
14182 quantity: self.state.shop_quantity,
14183 seq,
14184 })
14185 .await?;
14186 }
14187 ShopTab::Sell => {
14188 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
14189 anyhow::bail!("nothing to sell");
14190 };
14191 if line.quantity == 0 {
14192 anyhow::bail!("you have no {}", line.label);
14193 }
14194 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
14195 self.session
14196 .submit_intent(Intent::ShopSell {
14197 entity_id: self.state.entity_id,
14198 npc_id: catalog.npc_id.clone(),
14199 template_id: line.template_id.clone(),
14200 quantity,
14201 seq,
14202 })
14203 .await?;
14204 }
14205 }
14206 self.state.intents_sent += 1;
14207 Ok(())
14208 }
14209
14210 pub fn craft_menu_move(&mut self, delta: i32) {
14211 let n = self.state.craft_filtered_indices().len();
14212 if n == 0 {
14213 return;
14214 }
14215 let idx = self.state.craft_menu_index as i32;
14216 let next = (idx + delta).rem_euclid(n as i32);
14217 self.state.craft_menu_index = next as usize;
14218 self.state.clamp_craft_batch_quantity();
14219 }
14220
14221 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
14222 self.state.craft_batch_adjust_quantity(delta);
14223 }
14224
14225 pub fn craft_batch_set_max(&mut self) {
14226 self.state.craft_batch_set_max();
14227 }
14228
14229 pub fn craft_batch_set_min(&mut self) {
14230 self.state.craft_batch_set_min();
14231 }
14232
14233 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
14234 let Some(blueprint) = self.state.craft_selected_blueprint().cloned() else {
14235 anyhow::bail!("no blueprints in this tab");
14236 };
14237 if !self.state.can_craft_blueprint(&blueprint) {
14238 let hint = self
14239 .state
14240 .craft_missing_hint(&blueprint)
14241 .unwrap_or_else(|| "missing materials".into());
14242 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
14243 }
14244 let count = self.state.craft_batch_quantity;
14245 let max = self.state.max_craft_batches(&blueprint);
14246 if max == 0 {
14247 anyhow::bail!("cannot craft {}", blueprint.label);
14248 }
14249 let batches = count.min(max);
14250 self.craft(&blueprint.id, Some(batches)).await?;
14251 Ok(())
14253 }
14254
14255 pub async fn move_by(
14256 &mut self,
14257 forward: f32,
14258 strafe: f32,
14259 vertical: f32,
14260 sprint: bool,
14261 sneak: bool,
14262 ) -> anyhow::Result<()> {
14263 if !self.state.is_alive() {
14264 anyhow::bail!("you are dead");
14265 }
14266 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
14267 self.last_move_forward = forward;
14268 self.last_move_strafe = strafe;
14269 }
14270 self.seq += 1;
14271 self.session
14272 .submit_intent(Intent::Move {
14273 entity_id: self.state.entity_id,
14274 forward,
14275 strafe,
14276 vertical,
14277 sprint: sprint && !sneak,
14278 sneak,
14279 seq: self.seq,
14280 })
14281 .await?;
14282 self.state.intents_sent += 1;
14283 Ok(())
14284 }
14285
14286 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
14287 if !self.state.connected {
14288 crate::harvest_trace!("harvest_nearest rejected: not connected");
14289 anyhow::bail!("not connected");
14290 }
14291 if !self.state.is_alive() {
14292 crate::harvest_trace!("harvest_nearest rejected: player dead");
14293 anyhow::bail!("you are dead");
14294 }
14295 if self.state.harvest_in_progress {
14296 if self.state.harvest_state_stale() {
14297 self.state.clear_harvest_state();
14298 } else {
14299 anyhow::bail!("already harvesting");
14300 }
14301 }
14302 let (px, py) = self
14303 .state
14304 .player
14305 .as_ref()
14306 .map(|p| (p.transform.position.x, p.transform.position.y))
14307 .unwrap_or((0.0, 0.0));
14308
14309 let available = self
14310 .state
14311 .resource_nodes
14312 .iter()
14313 .filter(|n| !n.harvest_off)
14314 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14315 .count();
14316 let node_id = self
14317 .state
14318 .resource_nodes
14319 .iter()
14320 .filter(|n| !n.harvest_off)
14321 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14322 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
14323 .min_by(|a, b| {
14324 let da = distance(px, py, a.x, a.y);
14325 let db = distance(px, py, b.x, b.y);
14326 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
14327 })
14328 .map(|n| n.id.clone());
14329
14330 let Some(node_id) = node_id else {
14331 let has_loot = self
14332 .state
14333 .ground_drops
14334 .iter()
14335 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14336 if has_loot {
14337 return self.pickup_nearest().await;
14338 }
14339 anyhow::bail!(
14340 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
14341 );
14342 };
14343
14344 self.seq += 1;
14345 let seq = self.seq;
14346 crate::harvest_trace!(
14347 entity_id = self.state.entity_id,
14348 node_id = %node_id,
14349 seq,
14350 px,
14351 py,
14352 available_nodes = available,
14353 "submitting harvest intent"
14354 );
14355 self.session
14356 .submit_intent(Intent::Harvest {
14357 entity_id: self.state.entity_id,
14358 node_id,
14359 seq,
14360 })
14361 .await?;
14362 self.state.intents_sent += 1;
14363 self.state.harvest_in_progress = true;
14364 self.state.harvest_started_at = Some(Instant::now());
14365 self.state.push_log("Harvesting…");
14366 crate::harvest_trace!(
14367 entity_id = self.state.entity_id,
14368 seq,
14369 "harvest intent queued to session"
14370 );
14371 Ok(())
14372 }
14373
14374 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
14375 if !self.state.is_alive() {
14376 anyhow::bail!("you are dead");
14377 }
14378 let blueprint_id = self
14379 .state
14380 .blueprints
14381 .iter()
14382 .find(|bp| self.state.can_craft_blueprint(bp))
14383 .map(|bp| bp.id.clone())
14384 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
14385 self.craft(&blueprint_id, None).await
14386 }
14387
14388 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
14389 if !self.state.is_alive() {
14390 anyhow::bail!("you are dead");
14391 }
14392 self.seq += 1;
14393 self.session
14394 .submit_intent(Intent::Craft {
14395 entity_id: self.state.entity_id,
14396 blueprint_id: blueprint_id.to_string(),
14397 count,
14398 seq: self.seq,
14399 })
14400 .await?;
14401 self.state.intents_sent += 1;
14402 let (label, batches) = self
14403 .state
14404 .blueprints
14405 .iter()
14406 .find(|b| b.id == blueprint_id)
14407 .map(|b| {
14408 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
14409 (b.label.as_str(), n)
14410 })
14411 .unwrap_or((blueprint_id, count.unwrap_or(1)));
14412 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
14413 self.state.craft_channel_blueprint_id = Some(blueprint_id.to_string());
14414 Ok(())
14415 }
14416
14417 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
14418 if !self.state.is_alive() {
14419 anyhow::bail!("you are dead");
14420 }
14421 let target_id = match self.state.nearest_interact_target() {
14422 Some(id) => id,
14423 None => {
14424 anyhow::bail!("nothing to interact with nearby");
14425 }
14426 };
14427 if self.state.npcs.iter().any(|n| n.id == target_id) {
14428 self.state.show_npc_verb_menu = true;
14429 self.state.npc_verb_target = Some(target_id);
14430 self.state.npc_verb_index = 0;
14431 self.state.npc_verb_notice = None;
14432 return Ok(());
14433 }
14434 if self
14435 .state
14436 .hired_workers
14437 .iter()
14438 .any(|w| w.instance_id == target_id)
14439 {
14440 return self.open_workers_menu_for(&target_id).await;
14441 }
14442 if let Ok(peer_id) = target_id.parse::<EntityId>() {
14443 if self
14444 .state
14445 .hired_workers
14446 .iter()
14447 .any(|w| w.entity_id == peer_id)
14448 {
14449 if let Some(w) = self
14450 .state
14451 .hired_workers
14452 .iter()
14453 .find(|w| w.entity_id == peer_id)
14454 {
14455 let id = w.instance_id.clone();
14456 return self.open_workers_menu_for(&id).await;
14457 }
14458 }
14459 if let Some(entity) = self
14460 .state
14461 .entities
14462 .iter()
14463 .find(|e| e.id == peer_id && e.id != self.state.entity_id)
14464 {
14465 self.state.player_verbs.open_for(peer_id, &entity.label);
14466 return Ok(());
14467 }
14468 }
14469 self.seq += 1;
14470 self.session
14471 .submit_intent(Intent::Interact {
14472 entity_id: self.state.entity_id,
14473 target_id: target_id.clone(),
14474 seq: self.seq,
14475 })
14476 .await?;
14477 self.state.intents_sent += 1;
14478 Ok(())
14479 }
14480
14481 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
14486 if !self.state.is_alive() {
14487 anyhow::bail!("you are dead");
14488 }
14489 let (px, py) = self.state.player_position();
14490 let has_loot = self
14491 .state
14492 .ground_drops
14493 .iter()
14494 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14495 if has_loot {
14496 return self.pickup_nearest().await;
14497 }
14498
14499 if let Some(primary) = self.state.probe_use_world().primary {
14501 match primary.kind.cascade_stage() {
14502 0 => return self.interact_nearest().await,
14503 2 => return self.pickup_nearest_container().await,
14504 3 => return self.harvest_nearest().await,
14505 _ => {}
14506 }
14507 }
14508
14509 if let Some(plot) = self.state.my_plot_under_player().cloned() {
14510 const SELL_WINDOW: Duration = Duration::from_millis(1200);
14512 let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
14513 && self
14514 .state
14515 .sell_plot_armed_at
14516 .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
14517 if sell_armed {
14518 return self.confirm_sell_plot_to_crown(plot.plot_id).await;
14519 }
14520 self.state.sell_plot_confirm = None;
14521 self.state.sell_plot_armed_at = None;
14522
14523 let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
14526 self.state.npcs.iter().any(|n| n.id == id)
14527 || self.state.hired_workers.iter().any(|w| w.instance_id == id)
14528 || self.state.doors.iter().any(|d| d.id == id)
14529 || self.state.interactables.iter().any(|i| {
14530 i.id == id
14531 && matches!(i.kind.as_str(), "quest_board" | "well" | "exit" | "enter")
14532 })
14533 || id.parse::<EntityId>().is_ok_and(|eid| {
14534 self.state
14535 .entities
14536 .iter()
14537 .any(|e| e.id == eid && e.id != self.state.entity_id)
14538 })
14539 });
14540 if !blocking_interact {
14541 match self.harvest_nearest().await {
14543 Ok(()) => return Ok(()),
14544 Err(err) => {
14545 let msg = err.to_string();
14546 if !(msg.contains("no harvestable")
14547 || msg.contains("press p")
14548 || msg.contains("press f")
14549 || msg.contains("nothing"))
14550 {
14551 return Err(err);
14552 }
14553 }
14554 }
14555 return Ok(());
14556 }
14557 }
14558 if self.state.nearest_interact_target().is_some() {
14559 return self.interact_nearest().await;
14560 }
14561 if let Some((label, dist)) = self.state.nearest_quest_board() {
14564 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
14565 anyhow::bail!(
14566 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
14567 );
14568 }
14569 }
14570
14571 match self.harvest_nearest().await {
14572 Ok(()) => Ok(()),
14573 Err(err) => {
14574 let msg = err.to_string();
14575 if msg.contains("no harvestable")
14576 || msg.contains("press p")
14577 || msg.contains("press f")
14578 {
14579 anyhow::bail!(
14580 "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
14581 );
14582 }
14583 Err(err)
14584 }
14585 }
14586 }
14587
14588 pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
14590 if !self.state.is_alive() {
14591 anyhow::bail!("you are dead");
14592 }
14593 if self.state.claim_mode.is_some() {
14594 anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
14595 }
14596 let zone = self
14597 .state
14598 .free_property_zone_under_player()
14599 .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
14600 let zone_id = zone.id.clone();
14601 let label = zone
14602 .label
14603 .as_deref()
14604 .filter(|s| !s.trim().is_empty())
14605 .unwrap_or(zone.id.as_str())
14606 .to_string();
14607 self.enter_claim_mode(&zone_id);
14608 self.state.push_log(format!(
14609 "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
14610 ));
14611 Ok(())
14612 }
14613
14614 pub fn enter_claim_mode(&mut self, zone_id: &str) {
14616 let Some(zone) = self
14617 .state
14618 .property_zones
14619 .iter()
14620 .find(|z| z.id == zone_id)
14621 .cloned()
14622 else {
14623 self.state.push_log("unknown property zone");
14624 return;
14625 };
14626 self.state.sell_plot_confirm = None;
14627 self.state.sell_plot_armed_at = None;
14628 let min_area = self
14629 .state
14630 .property_plot_settings
14631 .as_ref()
14632 .map(|s| s.min_plot_area_m2)
14633 .unwrap_or(4.0)
14634 .max(1.0);
14635 let min_side = min_area.sqrt().ceil().max(1.0) as u32;
14636 let side = 4u32.max(min_side);
14637 let (px, py) = self.state.player_position();
14638 let anchor_x = px.floor();
14639 let anchor_y = py.floor();
14640 self.state.claim_mode = Some(ClaimModeState {
14641 zone_id: zone.id.clone(),
14642 width_m: side,
14643 height_m: side,
14644 anchor_x,
14645 anchor_y,
14646 });
14647 let label = zone
14648 .label
14649 .as_deref()
14650 .filter(|s| !s.trim().is_empty())
14651 .unwrap_or(zone.id.as_str());
14652 self.state.push_log(format!(
14653 "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
14654 ));
14655 }
14656
14657 pub fn cancel_claim_mode(&mut self) {
14658 if self.state.claim_mode.take().is_some() {
14659 self.state.push_log("Claim cancelled");
14660 }
14661 }
14662
14663 pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
14665 if !self.state.is_alive() {
14666 anyhow::bail!("you are dead");
14667 }
14668 if self.state.relocate_mode.is_some() {
14669 anyhow::bail!("already relocating — Enter confirm, Esc cancel");
14670 }
14671 if self.state.claim_mode.is_some() {
14672 anyhow::bail!("finish or cancel claim mode first");
14673 }
14674 let chest = self
14675 .state
14676 .placed_containers
14677 .iter()
14678 .find(|c| c.id == container_id)
14679 .cloned()
14680 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
14681 let (px, py) = self.state.player_position();
14682 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
14683 anyhow::bail!("too far from {}", chest.display_name);
14684 }
14685 if chest.locked && !chest.accessible {
14686 anyhow::bail!(
14687 "need the matching key for {} before moving it",
14688 chest.display_name
14689 );
14690 }
14691 let label = if chest.display_name.trim().is_empty() {
14692 chest.template_id.clone()
14693 } else {
14694 chest.display_name.clone()
14695 };
14696 self.state.relocate_mode = Some(RelocateModeState {
14697 container_id: chest.id.clone(),
14698 label: label.clone(),
14699 cursor_x: chest.x.floor() + 0.5,
14700 cursor_y: chest.y.floor() + 0.5,
14701 });
14702 self.state.push_log(format!(
14703 "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
14704 ));
14705 Ok(())
14706 }
14707
14708 pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
14710 let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
14711 anyhow::bail!("no chest nearby to relocate");
14712 };
14713 if chest.locked && !chest.accessible {
14714 anyhow::bail!(
14715 "need the matching key for {} before moving it",
14716 chest.display_name
14717 );
14718 }
14719 self.begin_relocate_container(&chest.id)
14722 }
14723
14724 pub fn cancel_relocate_mode(&mut self) {
14725 if self.state.relocate_mode.take().is_some() {
14726 self.state.push_log("Relocate cancelled");
14727 }
14728 }
14729
14730 pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
14731 let Some(mode) = self.state.relocate_mode.as_mut() else {
14732 return;
14733 };
14734 let max_x = self.state.world_width_m.max(1.0);
14735 let max_y = self.state.world_height_m.max(1.0);
14736 let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
14737 let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
14738 mode.cursor_x = nx.floor() + 0.5;
14739 mode.cursor_y = ny.floor() + 0.5;
14740 }
14741
14742 pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
14743 let Some(mode) = self.state.relocate_mode.as_mut() else {
14744 return;
14745 };
14746 let max_x = self.state.world_width_m.max(1.0);
14747 let max_y = self.state.world_height_m.max(1.0);
14748 mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
14749 mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
14750 }
14751
14752 pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
14753 if !self.state.is_alive() {
14754 anyhow::bail!("you are dead");
14755 }
14756 let Some(mode) = self.state.relocate_mode.clone() else {
14757 anyhow::bail!("not relocating");
14758 };
14759 let (px, py) = self.state.player_position();
14760 let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
14761 if dist > 8.0 {
14762 anyhow::bail!("destination too far (max 8 m)");
14763 }
14764 self.seq += 1;
14765 self.session
14766 .submit_intent(Intent::MovePlacedContainer {
14767 entity_id: self.state.entity_id,
14768 container_id: mode.container_id.clone(),
14769 x: mode.cursor_x,
14770 y: mode.cursor_y,
14771 seq: self.seq,
14772 })
14773 .await?;
14774 self.state.intents_sent += 1;
14775 self.state.relocate_mode = None;
14776 self.state.push_log(format!("Moving {}…", mode.label));
14777 Ok(())
14778 }
14779
14780 pub fn claim_set_preset(&mut self, w: u32, h: u32) {
14781 let Some(mode) = self.state.claim_mode.as_mut() else {
14782 return;
14783 };
14784 mode.width_m = w.max(1);
14785 mode.height_m = h.max(1);
14786 }
14787
14788 pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
14789 let Some(mode) = self.state.claim_mode.as_mut() else {
14790 return;
14791 };
14792 let w = (mode.width_m as i32 + dw).max(1) as u32;
14793 let h = (mode.height_m as i32 + dh).max(1) as u32;
14794 mode.width_m = w;
14795 mode.height_m = h;
14796 }
14797
14798 pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
14800 let Some(mode) = self.state.claim_mode.as_mut() else {
14801 return;
14802 };
14803 let max_x = self.state.world_width_m.max(1.0);
14804 let max_y = self.state.world_height_m.max(1.0);
14805 let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
14806 let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
14807 mode.anchor_x = nx.floor();
14808 mode.anchor_y = ny.floor();
14809 }
14810
14811 pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
14812 if !self.state.is_alive() {
14813 anyhow::bail!("you are dead");
14814 }
14815 let Some(mode) = self.state.claim_mode.clone() else {
14816 anyhow::bail!("not in claim mode");
14817 };
14818 let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
14819 self.state.claim_quote()
14820 else {
14821 anyhow::bail!("cannot quote claim");
14822 };
14823 if !valid {
14824 anyhow::bail!(reason);
14825 }
14826 if !can_afford {
14827 anyhow::bail!(
14828 "not enough copper (need {})",
14829 crate::currency::format_copper(purchase)
14830 );
14831 }
14832 let (x0, y0, x1, y1) = self
14833 .state
14834 .claim_footprint_rect()
14835 .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
14836 let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
14837 self.seq += 1;
14838 self.session
14839 .submit_intent(Intent::BuyPlot {
14840 entity_id: self.state.entity_id,
14841 zone_id: mode.zone_id,
14842 x0,
14843 y0,
14844 x1,
14845 y1,
14846 seq: self.seq,
14847 })
14848 .await?;
14849 self.state.intents_sent += 1;
14850 self.state.claim_mode = None;
14851 self.state.push_log(format!(
14852 "Buying plot for {}",
14853 crate::currency::format_copper(purchase)
14854 ));
14855 Ok(())
14856 }
14857
14858 pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
14859 if !self.state.is_alive() {
14860 anyhow::bail!("you are dead");
14861 }
14862 let zone_id = self
14863 .state
14864 .claim_mode
14865 .as_ref()
14866 .map(|m| m.zone_id.clone())
14867 .or_else(|| {
14868 self.state
14869 .free_property_zone_under_player()
14870 .map(|z| z.id.clone())
14871 })
14872 .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
14873 self.seq += 1;
14874 self.session
14875 .submit_intent(Intent::BuyPlotAllFree {
14876 entity_id: self.state.entity_id,
14877 zone_id,
14878 seq: self.seq,
14879 })
14880 .await?;
14881 self.state.intents_sent += 1;
14882 self.state.claim_mode = None;
14883 self.state.push_log("Claiming largest free plot…");
14884 Ok(())
14885 }
14886
14887 pub async fn confirm_sell_plot_to_crown(&mut self, plot_id: uuid::Uuid) -> anyhow::Result<()> {
14888 if !self.state.is_alive() {
14889 anyhow::bail!("you are dead");
14890 }
14891 self.seq += 1;
14892 self.session
14893 .submit_intent(Intent::SellPlotToCrown {
14894 entity_id: self.state.entity_id,
14895 plot_id,
14896 seq: self.seq,
14897 })
14898 .await?;
14899 self.state.intents_sent += 1;
14900 self.state.sell_plot_confirm = None;
14901 self.state.sell_plot_armed_at = None;
14902 self.state.push_log("Selling plot to the crown…");
14903 Ok(())
14904 }
14905
14906 pub async fn set_plot_farm_public(
14907 &mut self,
14908 plot_id: uuid::Uuid,
14909 public: bool,
14910 public_tax_discount_bps: u32,
14911 ) -> anyhow::Result<()> {
14912 self.seq += 1;
14913 self.session
14914 .submit_intent(Intent::SetPlotFarmPublic {
14915 entity_id: self.state.entity_id,
14916 plot_id,
14917 public,
14918 public_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_upsert(
14927 &mut self,
14928 plot_id: uuid::Uuid,
14929 character_id: Option<uuid::Uuid>,
14930 character_name: String,
14931 tax_discount_bps: u32,
14932 ) -> anyhow::Result<()> {
14933 self.seq += 1;
14934 self.session
14935 .submit_intent(Intent::PlotFarmAllowUpsert {
14936 entity_id: self.state.entity_id,
14937 plot_id,
14938 character_id,
14939 character_name,
14940 tax_discount_bps,
14941 seq: self.seq,
14942 })
14943 .await?;
14944 self.state.intents_sent += 1;
14945 Ok(())
14946 }
14947
14948 pub async fn plot_farm_allow_remove(
14949 &mut self,
14950 plot_id: uuid::Uuid,
14951 character_id: uuid::Uuid,
14952 ) -> anyhow::Result<()> {
14953 self.seq += 1;
14954 self.session
14955 .submit_intent(Intent::PlotFarmAllowRemove {
14956 entity_id: self.state.entity_id,
14957 plot_id,
14958 character_id,
14959 seq: self.seq,
14960 })
14961 .await?;
14962 self.state.intents_sent += 1;
14963 Ok(())
14964 }
14965
14966 pub fn open_farm_access_panel(&mut self) {
14967 let Some(plot) = self.state.my_plot_under_player() else {
14968 self.state
14969 .push_log("Stand on your deed plot to manage farm access");
14970 return;
14971 };
14972 self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
14973 self.state.farm_access_index = 0;
14974 self.state.show_farm_access = true;
14975 }
14976
14977 pub fn close_farm_access_panel(&mut self) {
14978 self.state.show_farm_access = false;
14979 self.state.farm_access_name_draft.clear();
14980 self.state.farm_access_index = 0;
14981 }
14982
14983 pub fn farm_access_move(&mut self, delta: i32) {
14984 let n = self.farm_access_row_count().max(1);
14985 let idx = self.state.farm_access_index as i32 + delta;
14986 self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
14987 }
14988
14989 pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
14990 let Some(plot) = self.state.my_plot_under_player() else {
14991 return vec![FarmAccessRow::PublicToggle];
14992 };
14993 let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
14994 for g in &plot.farm_allow {
14995 rows.push(FarmAccessRow::AllowRemove {
14996 character_id: g.character_id,
14997 label: if g.character_label.trim().is_empty() {
14998 g.character_id.to_string()[..8].to_string()
14999 } else {
15000 g.character_label.clone()
15001 },
15002 tax_discount_bps: g.tax_discount_bps,
15003 });
15004 }
15005 for e in &self.state.entities {
15006 if e.id == self.state.entity_id || e.label.trim().is_empty() {
15007 continue;
15008 }
15009 if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
15010 continue;
15011 }
15012 if self
15013 .state
15014 .npcs
15015 .iter()
15016 .any(|n| n.id == e.label || n.label == e.label)
15017 {
15018 continue;
15019 }
15020 if plot
15021 .farm_allow
15022 .iter()
15023 .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
15024 {
15025 continue;
15026 }
15027 rows.push(FarmAccessRow::NearbyAdd {
15028 name: e.label.clone(),
15029 });
15030 }
15031 rows
15032 }
15033
15034 pub fn farm_access_row_count(&self) -> usize {
15035 self.farm_access_rows().len().max(1)
15036 }
15037
15038 pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
15039 let Some(plot) = self.state.my_plot_under_player().cloned() else {
15040 self.close_farm_access_panel();
15041 return Ok(());
15042 };
15043 let rows = self.farm_access_rows();
15044 let Some(row) = rows.get(self.state.farm_access_index) else {
15045 return Ok(());
15046 };
15047 match row {
15048 FarmAccessRow::PublicToggle => {
15049 self.set_plot_farm_public(
15050 plot.plot_id,
15051 !plot.farm_public,
15052 plot.public_tax_discount_bps,
15053 )
15054 .await
15055 }
15056 FarmAccessRow::PublicDiscount => Ok(()),
15057 FarmAccessRow::AllowRemove { character_id, .. } => {
15058 self.plot_farm_allow_remove(plot.plot_id, *character_id)
15059 .await
15060 }
15061 FarmAccessRow::NearbyAdd { name } => {
15062 let disc = self
15063 .state
15064 .farm_access_discount_bps
15065 .max(plot.public_tax_discount_bps);
15066 self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
15067 .await
15068 }
15069 }
15070 }
15071
15072 pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
15073 let Some(plot) = self.state.my_plot_under_player().cloned() else {
15074 return Ok(());
15075 };
15076 let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
15077 self.state.farm_access_discount_bps = next;
15078 self.state.farm_access_index = 1;
15079 self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
15080 .await
15081 }
15082
15083 pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
15085 if self.state.farmable_plot_under_player().is_none() {
15086 anyhow::bail!("stand on a farmable plot to cultivate");
15087 }
15088 let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
15089 let (px, py) = self.state.player_position();
15090 if self
15091 .state
15092 .terrain_at(px, py)
15093 .is_some_and(|k| k == TerrainKindView::Tilled)
15094 {
15095 anyhow::bail!("already tilled — stand on bare soil and press c");
15096 }
15097 anyhow::bail!("cannot till this cell — move onto soil on your plot");
15098 };
15099 self.cultivate_at(tx, ty).await
15100 }
15101
15102 pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
15104 if self.state.farmable_plot_under_player().is_none() {
15105 anyhow::bail!("stand on a farmable plot to plant");
15106 }
15107 if !self.state.underfoot_free_tilled_plant_slot() {
15108 anyhow::bail!("stand on empty tilled soil and press p");
15109 }
15110 let seeds = self.state.farm_seed_entries();
15111 if seeds.is_empty() {
15112 anyhow::bail!("no seeds in inventory — buy seeds from Eli");
15113 }
15114 if seeds.len() == 1 {
15115 return self.plant_seeds(seeds[0].0.clone(), 1).await;
15116 }
15117 self.open_plant_menu();
15118 Ok(())
15119 }
15120
15121 pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
15123 let Some(plot) = self.state.my_plot_under_player() else {
15124 anyhow::bail!("stand on your plot to build");
15125 };
15126 if plot.building_id.is_some() {
15127 anyhow::bail!("this plot already has a building");
15128 }
15129 let building_now = self
15130 .state
15131 .timed_channel
15132 .as_ref()
15133 .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
15134 if !building_now && self.state.building_materials.is_empty() {
15135 anyhow::bail!("no building materials loaded — wait a moment and try again");
15136 }
15137 self.state.show_plot_build_menu = true;
15138 self.state.show_craft_menu = false;
15139 self.state.show_shop_menu = false;
15140 self.state.shop_catalog = None;
15141 self.state.show_stats = false;
15142 self.state.show_inventory_menu = false;
15143 self.state.plot_build_focus_wall = true;
15144 let walls = self.state.plot_build_wall_options().len();
15145 let roofs = self.state.plot_build_roof_options().len();
15146 if walls > 0 {
15147 self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
15148 } else {
15149 self.state.plot_build_wall_index = 0;
15150 }
15151 if roofs > 0 {
15152 self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
15153 } else {
15154 self.state.plot_build_roof_index = 0;
15155 }
15156 Ok(())
15157 }
15158
15159 pub fn close_plot_build_menu(&mut self) {
15160 self.state.show_plot_build_menu = false;
15161 }
15162
15163 pub fn plot_build_menu_move(&mut self, delta: i32) {
15164 let walls = self.state.plot_build_wall_options();
15165 let roofs = self.state.plot_build_roof_options();
15166 if self.state.plot_build_focus_wall {
15167 if walls.is_empty() {
15168 return;
15169 }
15170 let n = walls.len() as i32;
15171 let cur = self.state.plot_build_wall_index as i32;
15172 self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
15173 } else {
15174 if roofs.is_empty() {
15175 return;
15176 }
15177 let n = roofs.len() as i32;
15178 let cur = self.state.plot_build_roof_index as i32;
15179 self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
15180 }
15181 }
15182
15183 pub fn plot_build_menu_toggle_focus(&mut self) {
15184 self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
15185 }
15186
15187 pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
15189 let wall = self
15190 .state
15191 .plot_build_selected_wall()
15192 .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
15193 .id
15194 .clone();
15195 let roof = self
15196 .state
15197 .plot_build_selected_roof()
15198 .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
15199 .id
15200 .clone();
15201 self.start_plot_build(&wall, &roof).await
15203 }
15204
15205 pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
15207 self.seq += 1;
15208 self.session
15209 .submit_intent(Intent::CancelPlotBuild {
15210 entity_id: self.state.entity_id,
15211 seq: self.seq,
15212 })
15213 .await?;
15214 self.state.intents_sent += 1;
15215 Ok(())
15216 }
15217
15218 pub async fn start_plot_build(
15220 &mut self,
15221 wall_material_id: &str,
15222 roof_material_id: &str,
15223 ) -> anyhow::Result<()> {
15224 let Some(plot) = self.state.my_plot_under_player() else {
15225 anyhow::bail!("stand on your plot to build");
15226 };
15227 if plot.building_id.is_some() {
15228 anyhow::bail!("this plot already has a building");
15229 }
15230 let plot_id = plot.plot_id;
15231 self.seq += 1;
15232 self.session
15233 .submit_intent(Intent::StartPlotBuild {
15234 entity_id: self.state.entity_id,
15235 plot_id,
15236 wall_material_id: wall_material_id.to_string(),
15237 roof_material_id: roof_material_id.to_string(),
15238 seq: self.seq,
15239 })
15240 .await?;
15241 self.state.intents_sent += 1;
15242 Ok(())
15243 }
15244
15245 pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
15247 let (px, py) = self.state.player_position();
15248 let mut best: Option<(f32, String, bool)> = None;
15249 for d in &self.state.doors {
15250 if d.lock_id.is_none() {
15251 continue;
15252 }
15253 let dist = (d.x - px).hypot(d.y - py);
15254 if dist > DOOR_INTERACTION_RADIUS_M {
15255 continue;
15256 }
15257 if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
15258 best = Some((dist, d.id.clone(), d.locked));
15259 }
15260 }
15261 let Some((_, door_id, locked_now)) = best else {
15262 anyhow::bail!("no lockable door nearby");
15263 };
15264 let locked = !locked_now;
15265 self.seq += 1;
15266 self.session
15267 .submit_intent(Intent::SetDoorLocked {
15268 entity_id: self.state.entity_id,
15269 door_id,
15270 locked,
15271 seq: self.seq,
15272 })
15273 .await?;
15274 self.state.intents_sent += 1;
15275 Ok(())
15276 }
15277
15278 pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
15280 if !self.state.is_alive() {
15281 anyhow::bail!("you are dead");
15282 }
15283 if self.state.effective_inside_building().is_some() {
15284 anyhow::bail!("already inside");
15285 }
15286 let (px, py) = self.state.player_position();
15287 let mut best: Option<(f32, String)> = None;
15288 for d in &self.state.doors {
15289 if !d.open || d.locked {
15290 continue;
15291 }
15292 let player_house = self
15293 .state
15294 .buildings
15295 .iter()
15296 .find(|b| b.id == d.building_id)
15297 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15298 if !player_house {
15299 continue;
15300 }
15301 let dist = (d.x - px).hypot(d.y - py);
15302 if dist > DOOR_INTERACTION_RADIUS_M {
15303 continue;
15304 }
15305 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15306 best = Some((dist, d.id.clone()));
15307 }
15308 }
15309 let Some((_, door_id)) = best else {
15310 anyhow::bail!("no open house door nearby — open with f first");
15311 };
15312 self.seq += 1;
15313 self.session
15314 .submit_intent(Intent::EnterBuildingDoor {
15315 entity_id: self.state.entity_id,
15316 door_id,
15317 seq: self.seq,
15318 })
15319 .await?;
15320 self.state.intents_sent += 1;
15321 Ok(())
15322 }
15323
15324 pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
15327 if !self.state.is_alive() {
15328 anyhow::bail!("you are dead");
15329 }
15330 let Some(bid) = self.state.effective_inside_building() else {
15331 anyhow::bail!("not inside a building");
15332 };
15333 let (px, py) = self.state.player_position();
15334 let mut best: Option<(f32, String)> = None;
15335 for d in &self.state.doors {
15336 if d.building_id != bid || d.portal.is_none() {
15337 continue;
15338 }
15339 let player_house = self
15340 .state
15341 .buildings
15342 .iter()
15343 .find(|b| b.id == d.building_id)
15344 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15345 if !player_house {
15346 continue;
15347 }
15348 let dist = (d.x - px).hypot(d.y - py);
15349 if dist > 1.5 {
15350 continue;
15351 }
15352 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15353 best = Some((dist, d.id.clone()));
15354 }
15355 }
15356 let Some((_, door_id)) = best else {
15357 anyhow::bail!("stand by the door to exit");
15358 };
15359 self.seq += 1;
15360 self.session
15361 .submit_intent(Intent::ExitBuildingDoor {
15362 entity_id: self.state.entity_id,
15363 door_id,
15364 seq: self.seq,
15365 })
15366 .await?;
15367 self.state.intents_sent += 1;
15368 Ok(())
15369 }
15370
15371 pub async fn confirm_interior_edit(
15373 &mut self,
15374 building_id: String,
15375 rooms: Vec<flatland_protocol::InteriorRoomEdit>,
15376 room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
15377 ) -> anyhow::Result<()> {
15378 self.seq += 1;
15379 self.session
15380 .submit_intent(Intent::ConfirmInteriorEdit {
15381 entity_id: self.state.entity_id,
15382 building_id,
15383 rooms,
15384 room_doors,
15385 seq: self.seq,
15386 })
15387 .await?;
15388 self.state.intents_sent += 1;
15389 Ok(())
15390 }
15391
15392 pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
15393 if !self.state.is_alive() {
15394 anyhow::bail!("you are dead");
15395 }
15396 self.seq += 1;
15397 self.session
15398 .submit_intent(Intent::Cultivate {
15399 entity_id: self.state.entity_id,
15400 x,
15401 y,
15402 seq: self.seq,
15403 })
15404 .await?;
15405 self.state.intents_sent += 1;
15406 Ok(())
15407 }
15408
15409 pub async fn plant_seeds(
15410 &mut self,
15411 seed_template_id: String,
15412 quantity: u32,
15413 ) -> anyhow::Result<()> {
15414 if !self.state.is_alive() {
15415 anyhow::bail!("you are dead");
15416 }
15417 self.seq += 1;
15418 self.session
15419 .submit_intent(Intent::PlantSeeds {
15420 entity_id: self.state.entity_id,
15421 seed_template_id: seed_template_id.clone(),
15422 quantity,
15423 seq: self.seq,
15424 })
15425 .await?;
15426 self.state.intents_sent += 1;
15427 self.state
15428 .push_log(format!("Planting {quantity}× {seed_template_id}…"));
15429 Ok(())
15430 }
15431
15432 pub fn open_plant_menu(&mut self) {
15433 if self.state.farm_seed_entries().is_empty() {
15434 self.state.push_log("No seeds in inventory to plant");
15435 return;
15436 }
15437 self.state.show_plant_menu = true;
15438 self.state.plant_menu_index = 0;
15439 self.state.plant_quantity = 1;
15440 self.state.clamp_plant_menu();
15441 }
15442
15443 pub fn close_plant_menu(&mut self) {
15444 self.state.show_plant_menu = false;
15445 }
15446
15447 pub fn plant_menu_move(&mut self, delta: i32) {
15448 let n = self.state.farm_seed_entries().len();
15449 if n == 0 {
15450 return;
15451 }
15452 let idx = self.state.plant_menu_index as i32 + delta;
15453 self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
15454 self.state.clamp_plant_menu();
15455 }
15456
15457 pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
15458 let next = self.state.plant_quantity as i32 + delta;
15459 self.state.plant_quantity = next.max(1) as u32;
15460 self.state.clamp_plant_menu();
15461 }
15462
15463 pub fn plant_menu_set_quantity_max(&mut self) {
15464 if let Some((_, max, _)) = self.state.plant_menu_selection() {
15465 self.state.plant_quantity = max;
15466 }
15467 self.state.clamp_plant_menu();
15468 }
15469
15470 pub fn plant_menu_set_quantity_min(&mut self) {
15471 self.state.plant_quantity = 1;
15472 self.state.clamp_plant_menu();
15473 }
15474
15475 pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
15476 let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
15477 self.close_plant_menu();
15478 anyhow::bail!("no seeds to plant");
15479 };
15480 self.close_plant_menu();
15481 self.plant_seeds(seed, qty).await?;
15482 self.state.push_log(format!("Planted {qty}× {label}"));
15483 Ok(())
15484 }
15485
15486 pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
15489 if !self.state.is_alive() {
15490 anyhow::bail!("you are dead");
15491 }
15492 let binding = self
15493 .state
15494 .hotbar_ability(slot)
15495 .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
15496 .to_string();
15497 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
15498 let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
15499 if qty == 0 {
15500 anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
15501 }
15502 return self.use_item(template_id).await;
15503 }
15504 let ability_id = binding;
15505 if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
15506 return self
15507 .cast_ability(&ability_id, Some(self.state.entity_id))
15508 .await;
15509 }
15510 let is_heal = ability_id == "heal_touch"
15511 || self
15512 .state
15513 .ability_meta
15514 .get(&ability_id)
15515 .map(|meta| meta.is_heal)
15516 .unwrap_or(false);
15517 let target = if is_heal {
15518 Some(
15519 self.state
15520 .target_for_slot(2)
15521 .unwrap_or(self.state.entity_id),
15522 )
15523 } else {
15524 self.state
15525 .target_for_slot(1)
15526 .or_else(|| self.state.target_for_slot(2))
15527 };
15528 let Some(target_id) = target else {
15529 anyhow::bail!("no target — Tab to select, then press the hotbar key");
15530 };
15531 self.cast_ability(&ability_id, Some(target_id)).await
15532 }
15533
15534 pub async fn set_hotbar_slot(
15537 &mut self,
15538 slot: u8,
15539 ability_id: Option<&str>,
15540 ) -> anyhow::Result<()> {
15541 if !self.state.is_alive() {
15542 anyhow::bail!("you are dead");
15543 }
15544 if !(1..=9).contains(&slot) {
15545 anyhow::bail!("hotbar slot must be 1–9");
15546 }
15547 let ability_id = ability_id
15548 .map(str::trim)
15549 .filter(|id| !id.is_empty())
15550 .map(str::to_string);
15551 self.seq += 1;
15552 self.session
15553 .submit_intent(Intent::SetHotbarSlot {
15554 entity_id: self.state.entity_id,
15555 slot,
15556 ability_id: ability_id.clone(),
15557 seq: self.seq,
15558 })
15559 .await?;
15560 self.state.intents_sent += 1;
15561 let idx = (slot - 1) as usize;
15562 if self.state.hotbar.len() < 9 {
15563 self.state.hotbar.resize(9, None);
15564 }
15565 if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
15566 *slot_mut = ability_id.clone();
15567 }
15568 match ability_id {
15569 Some(id) => {
15570 let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
15571 format!("use {tid}")
15572 } else {
15573 id
15574 };
15575 self.state.push_log(format!("Hotbar {slot} → {label}"))
15576 }
15577 None => self.state.push_log(format!("Hotbar {slot} cleared")),
15578 }
15579 Ok(())
15580 }
15581
15582 pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
15583 self.state.npc_verb_options()
15584 }
15585
15586 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
15587 let Some(npc_id) = self.state.npc_verb_target.clone() else {
15588 return Ok(());
15589 };
15590 let options = self.npc_verb_options();
15591 let choice = options
15592 .get(self.state.npc_verb_index)
15593 .cloned()
15594 .unwrap_or_else(GameState::talk_choice);
15595 match choice.action {
15596 NpcVerbAction::QuestGive { quest_id } => {
15597 self.submit_npc_quest_turn_in(&npc_id, Some(&quest_id))
15598 .await?;
15599 self.state.show_npc_verb_menu = false;
15600 }
15601 NpcVerbAction::Talk => {
15602 self.open_npc_talk(&npc_id, None).await?;
15603 }
15604 NpcVerbAction::QuestTalk { quest_id } => {
15605 self.open_npc_talk(&npc_id, Some(&quest_id)).await?;
15606 }
15607 NpcVerbAction::Trade | NpcVerbAction::Bank | NpcVerbAction::Storage | NpcVerbAction::Market => {
15608 self.seq += 1;
15609 self.session
15610 .submit_intent(Intent::Interact {
15611 entity_id: self.state.entity_id,
15612 target_id: npc_id,
15613 seq: self.seq,
15614 })
15615 .await?;
15616 self.state.intents_sent += 1;
15617 }
15618 }
15619 Ok(())
15620 }
15621
15622 async fn open_npc_talk(&mut self, npc_id: &str, quest_id: Option<&str>) -> anyhow::Result<()> {
15623 self.seq += 1;
15624 self.session
15625 .submit_intent(Intent::NpcTalkOpen {
15626 entity_id: self.state.entity_id,
15627 npc_id: npc_id.to_string(),
15628 quest_id: quest_id.map(str::to_string),
15629 seq: self.seq,
15630 })
15631 .await?;
15632 self.state.intents_sent += 1;
15633 Ok(())
15634 }
15635
15636 async fn submit_npc_quest_turn_in(
15637 &mut self,
15638 npc_id: &str,
15639 quest_id: Option<&str>,
15640 ) -> anyhow::Result<()> {
15641 let catalog_ref = self.state.npc_quest_catalog_ref(npc_id);
15642 let pending: Vec<(String, u32, String)> = self
15643 .state
15644 .quest_log
15645 .iter()
15646 .filter(|q| {
15647 q.status == flatland_protocol::QuestStatusView::Active
15648 && quest_id.is_none_or(|id| q.quest_id == id)
15649 })
15650 .flat_map(|q| q.objectives.iter())
15651 .filter(|o| {
15652 !o.done
15653 && o.kind == "give_item"
15654 && o.npc_ref.as_deref() == Some(catalog_ref.as_str())
15655 })
15656 .filter_map(|o| {
15657 let template = o.item_template.clone()?;
15658 let remaining = o.required.saturating_sub(o.current);
15659 if remaining == 0 {
15660 return None;
15661 }
15662 Some((template, remaining, o.label.clone()))
15663 })
15664 .collect();
15665 if pending.is_empty() {
15666 self.state.push_log("Nothing to turn in here.");
15667 return Ok(());
15668 }
15669 let mut sent = 0u32;
15670 for (template, remaining, label) in pending {
15671 let held = self.state.count_inventory_template(&template);
15672 let qty = remaining.min(held);
15673 if qty == 0 {
15674 self.state.push_log(format!("Need {label}"));
15675 continue;
15676 }
15677 self.seq += 1;
15678 self.session
15679 .submit_intent(Intent::QuestGiveItem {
15680 entity_id: self.state.entity_id,
15681 npc_id: npc_id.to_string(),
15682 template_id: template,
15683 quantity: qty,
15684 seq: self.seq,
15685 })
15686 .await?;
15687 self.state.intents_sent += 1;
15688 sent += 1;
15689 }
15690 if sent > 0 {
15691 self.state.push_log("Turning in quest items.");
15692 }
15693 Ok(())
15694 }
15695
15696 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
15697 let Some(chat) = self.state.npc_chat.clone() else {
15698 return Ok(());
15699 };
15700 let message = chat.input.trim().to_string();
15701 if message.is_empty() || chat.pending {
15702 return Ok(());
15703 }
15704 if let Some(c) = self.state.npc_chat.as_mut() {
15705 c.lines.push(format!("You: {message}"));
15706 c.input.clear();
15707 c.pending = true;
15708 }
15709 self.seq += 1;
15710 self.session
15711 .submit_intent(Intent::NpcTalkSay {
15712 entity_id: self.state.entity_id,
15713 npc_id: chat.npc_id,
15714 message,
15715 seq: self.seq,
15716 })
15717 .await?;
15718 self.state.intents_sent += 1;
15719 Ok(())
15720 }
15721
15722 pub async fn npc_talk_topic(&mut self, index: usize) -> anyhow::Result<()> {
15723 let topic = self
15724 .state
15725 .npc_chat
15726 .as_ref()
15727 .and_then(|c| c.suggested_topics.get(index))
15728 .cloned();
15729 let Some(topic) = topic else {
15730 return Ok(());
15731 };
15732 if let Some(c) = self.state.npc_chat.as_mut() {
15733 if c.pending {
15734 return Ok(());
15735 }
15736 c.input = topic;
15737 }
15738 self.npc_talk_send().await
15739 }
15740
15741 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
15742 let return_to_verbs = self.state.npc_verb_target.is_some();
15743 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
15744 self.state.show_npc_chat = false;
15745 if return_to_verbs {
15746 self.state.show_npc_verb_menu = true;
15747 }
15748 return Ok(());
15749 };
15750 self.seq += 1;
15751 self.session
15752 .submit_intent(Intent::NpcTalkClose {
15753 entity_id: self.state.entity_id,
15754 npc_id,
15755 seq: self.seq,
15756 })
15757 .await?;
15758 self.state.intents_sent += 1;
15759 self.state.show_npc_chat = false;
15760 self.state.npc_chat = None;
15761 if return_to_verbs {
15762 self.state.show_npc_verb_menu = true;
15763 self.state.npc_verb_notice = None;
15764 }
15765 Ok(())
15766 }
15767
15768 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
15770 if self.state.show_quest_offer
15771 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
15772 {
15773 self.quest_offer_decline();
15774 return Ok(());
15775 }
15776 if self.state.show_npc_chat {
15777 return self.npc_talk_close().await;
15778 }
15779 if self.state.show_shop_menu {
15780 return self.back_from_shop_menu().await;
15781 }
15782 if self.state.bank_panel.is_some() {
15783 if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
15784 self.bank_transfer_back();
15785 return Ok(());
15786 }
15787 return self.close_bank_panel().await;
15788 }
15789 if self.state.storage_panel.is_some() {
15790 if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
15791 self.storage_ui_back();
15792 return Ok(());
15793 }
15794 return self.close_storage_panel().await;
15795 }
15796 if self.state.market_panel.is_some() {
15797 if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
15798 self.market_ui_back();
15799 return Ok(());
15800 }
15801 if self.state.market_buy_confirm.is_some() {
15802 self.state.market_buy_confirm = None;
15803 return Ok(());
15804 }
15805 return self.close_market_panel().await;
15806 }
15807 if self.state.show_npc_verb_menu {
15808 self.state.show_npc_verb_menu = false;
15809 self.state.npc_verb_target = None;
15810 }
15811 Ok(())
15812 }
15813
15814 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
15815 self.seq += 1;
15816 self.session
15817 .submit_intent(Intent::TestDamage {
15818 entity_id: self.state.entity_id,
15819 amount,
15820 seq: self.seq,
15821 })
15822 .await?;
15823 self.state.intents_sent += 1;
15824 Ok(())
15825 }
15826
15827 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
15828 self.cycle_combat_target_slot(1, reverse).await
15829 }
15830
15831 pub async fn cycle_combat_target_slot(
15832 &mut self,
15833 slot_index: u8,
15834 reverse: bool,
15835 ) -> anyhow::Result<()> {
15836 if !self.state.is_alive() {
15837 anyhow::bail!("you are dead");
15838 }
15839 let candidates = self.state.candidates_for_slot(slot_index);
15840 if candidates.is_empty() {
15841 anyhow::bail!("no targets nearby");
15842 }
15843 let current = self.state.target_for_slot(slot_index);
15844 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
15845 let next_idx = match idx {
15846 None => 0,
15847 Some(i) if reverse => {
15848 if i == 0 {
15849 candidates.len() - 1
15850 } else {
15851 i - 1
15852 }
15853 }
15854 Some(i) => (i + 1) % candidates.len(),
15855 };
15856 if idx == Some(next_idx) && candidates.len() == 1 {
15857 self.clear_combat_target_slot(slot_index).await?;
15858 return Ok(());
15859 }
15860 let (target_id, label) = candidates[next_idx].clone();
15861 self.set_combat_target_slot(slot_index, target_id, &label)
15862 .await
15863 }
15864
15865 pub async fn set_combat_target_slot(
15866 &mut self,
15867 slot_index: u8,
15868 target_id: EntityId,
15869 label: &str,
15870 ) -> anyhow::Result<()> {
15871 if !self.state.is_alive() {
15872 anyhow::bail!("you are dead");
15873 }
15874 self.seq += 1;
15875 self.session
15876 .submit_intent(Intent::SetTargetSlot {
15877 entity_id: self.state.entity_id,
15878 slot_index,
15879 target_id,
15880 seq: self.seq,
15881 })
15882 .await?;
15883 self.state.intents_sent += 1;
15884 if slot_index == 1 {
15885 self.state.combat_target = Some(target_id);
15886 self.state.combat_target_label = Some(label.to_string());
15887 }
15888 self.state
15889 .push_log(format!("Slot {slot_index} target: {label}"));
15890 Ok(())
15891 }
15892
15893 pub async fn set_combat_target(
15894 &mut self,
15895 target_id: EntityId,
15896 label: &str,
15897 ) -> anyhow::Result<()> {
15898 self.set_combat_target_slot(1, target_id, label).await
15899 }
15900
15901 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
15902 if slot_index == 1 && self.state.combat_target.is_none() {
15903 return Ok(());
15904 }
15905 self.seq += 1;
15906 self.session
15907 .submit_intent(Intent::ClearTargetSlot {
15908 entity_id: self.state.entity_id,
15909 slot_index,
15910 seq: self.seq,
15911 })
15912 .await?;
15913 if slot_index == 1 {
15914 self.state.combat_target = None;
15915 self.state.combat_target_label = None;
15916 }
15917 self.state.intents_sent += 1;
15918 self.state
15919 .push_log(format!("Slot {slot_index} target cleared"));
15920 Ok(())
15921 }
15922
15923 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
15924 self.clear_combat_target_slot(1).await
15925 }
15926
15927 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
15928 if !self.state.is_alive() {
15929 anyhow::bail!("you are dead");
15930 }
15931 self.seq += 1;
15932 self.session
15933 .submit_intent(Intent::AdvanceRotation {
15934 entity_id: self.state.entity_id,
15935 slot_index,
15936 seq: self.seq,
15937 })
15938 .await?;
15939 self.state.intents_sent += 1;
15940 Ok(())
15941 }
15942
15943 pub async fn assign_slot_preset(
15944 &mut self,
15945 slot_index: u8,
15946 preset_id: &str,
15947 ) -> anyhow::Result<()> {
15948 if !self.state.is_alive() {
15949 anyhow::bail!("you are dead");
15950 }
15951 self.seq += 1;
15952 self.session
15953 .submit_intent(Intent::AssignSlotPreset {
15954 entity_id: self.state.entity_id,
15955 slot_index,
15956 preset_id: preset_id.to_string(),
15957 seq: self.seq,
15958 })
15959 .await?;
15960 self.state.intents_sent += 1;
15961 if let Some(slot) = self
15962 .state
15963 .combat_slots
15964 .iter_mut()
15965 .find(|s| s.slot_index == slot_index)
15966 {
15967 slot.preset_id = Some(preset_id.to_string());
15968 if let Some(preset) = self
15969 .state
15970 .rotation_presets
15971 .iter()
15972 .find(|p| p.id == preset_id)
15973 {
15974 slot.preset_label = Some(preset.label.clone());
15975 slot.rotation = preset.abilities.clone();
15976 slot.rotation_index = 0;
15977 }
15978 }
15979 self.state
15980 .push_log(format!("T{slot_index} loadout → {preset_id}"));
15981 Ok(())
15982 }
15983
15984 pub async fn cast_ability(
15985 &mut self,
15986 ability_id: &str,
15987 target_id: Option<EntityId>,
15988 ) -> anyhow::Result<()> {
15989 if !self.state.is_alive() {
15990 anyhow::bail!("you are dead");
15991 }
15992 let allows_ground = self.state.ability_allows_ground(ability_id);
15993 let requires_ground = self.state.ability_requires_ground(ability_id);
15994 if requires_ground && self.state.ground_target.is_none() {
15995 anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
15996 }
15997 let (resolved_target_id, target_point) = if allows_ground {
15998 if let Some((x, y, z)) = self.state.ground_target {
15999 (
16000 target_id.unwrap_or(self.state.entity_id),
16001 Some(flatland_protocol::AimPoint { x, y, z }),
16002 )
16003 } else {
16004 (
16005 target_id
16006 .or_else(|| self.state.target_for_slot(2))
16007 .or_else(|| self.state.target_for_slot(1))
16008 .unwrap_or(self.state.entity_id),
16009 None,
16010 )
16011 }
16012 } else {
16013 (
16014 target_id
16015 .or_else(|| self.state.target_for_slot(2))
16016 .or_else(|| self.state.target_for_slot(1))
16017 .unwrap_or(self.state.entity_id),
16018 None,
16019 )
16020 };
16021 self.seq += 1;
16022 self.session
16023 .submit_intent(Intent::Cast {
16024 entity_id: self.state.entity_id,
16025 ability_id: ability_id.to_string(),
16026 target_id: resolved_target_id,
16027 target_point,
16028 seq: self.seq,
16029 })
16030 .await?;
16031 self.state.intents_sent += 1;
16032 match target_point {
16033 Some(point) => self.state.push_log(format!(
16034 "Cast {ability_id} → ({:.1}, {:.1})",
16035 point.x, point.y
16036 )),
16037 None => self
16038 .state
16039 .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
16040 }
16041 Ok(())
16042 }
16043
16044 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
16045 self.seq += 1;
16046 self.session
16047 .submit_intent(Intent::UpsertRotationPreset {
16048 entity_id: self.state.entity_id,
16049 preset: preset.clone(),
16050 seq: self.seq,
16051 })
16052 .await?;
16053 self.state.intents_sent += 1;
16054 if let Some(existing) = self
16055 .state
16056 .rotation_presets
16057 .iter_mut()
16058 .find(|p| p.id == preset.id)
16059 {
16060 *existing = preset.clone();
16061 } else {
16062 self.state.rotation_presets.push(preset.clone());
16063 }
16064 for slot in &mut self.state.combat_slots {
16065 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
16066 slot.preset_label = Some(preset.label.clone());
16067 slot.rotation = preset.abilities.clone();
16068 }
16069 }
16070 self.state
16071 .push_log(format!("Saved rotation: {}", preset.label));
16072 Ok(())
16073 }
16074
16075 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
16076 self.seq += 1;
16077 self.session
16078 .submit_intent(Intent::DeleteRotationPreset {
16079 entity_id: self.state.entity_id,
16080 preset_id: preset_id.to_string(),
16081 seq: self.seq,
16082 })
16083 .await?;
16084 self.state.intents_sent += 1;
16085 self.state.rotation_presets.retain(|p| p.id != preset_id);
16086 for slot in &mut self.state.combat_slots {
16087 if slot.preset_id.as_deref() == Some(preset_id) {
16088 slot.preset_id = None;
16089 slot.preset_label = None;
16090 slot.rotation.clear();
16091 slot.rotation_index = 0;
16092 }
16093 }
16094 self.state
16095 .push_log(format!("Deleted rotation: {preset_id}"));
16096 Ok(())
16097 }
16098
16099 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
16100 if !self.state.is_alive() {
16101 anyhow::bail!("you are dead");
16102 }
16103 let enabled = !self
16104 .state
16105 .combat_slots
16106 .iter()
16107 .find(|s| s.slot_index == slot_index)
16108 .map(|s| s.auto_enabled)
16109 .unwrap_or(false);
16110 self.seq += 1;
16111 self.session
16112 .submit_intent(Intent::SetAutoAttack {
16113 entity_id: self.state.entity_id,
16114 slot_index,
16115 enabled,
16116 seq: self.seq,
16117 })
16118 .await?;
16119 if slot_index == 1 {
16120 self.state.auto_attack = enabled;
16121 }
16122 self.state.intents_sent += 1;
16123 self.state.push_log(format!(
16124 "T{slot_index} auto {}",
16125 if enabled { "ON" } else { "OFF" }
16126 ));
16127 Ok(())
16128 }
16129
16130 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
16131 if !self.state.connected {
16132 anyhow::bail!("not connected");
16133 }
16134 if !self.state.is_alive() {
16135 anyhow::bail!("you are dead");
16136 }
16137 let (px, py) = self.state.player_position();
16138 if self
16139 .state
16140 .ground_drops
16141 .iter()
16142 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
16143 {
16144 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
16145 }
16146 self.seq += 1;
16147 self.session
16148 .submit_intent(Intent::Pickup {
16149 entity_id: self.state.entity_id,
16150 drop_id: None,
16151 seq: self.seq,
16152 })
16153 .await?;
16154 self.state.intents_sent += 1;
16155 self.state.push_audio(crate::social::AudioCue::LootPickup);
16156 Ok(())
16157 }
16158
16159 pub async fn dodge(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16160 if !self.state.is_alive() {
16161 anyhow::bail!("you are dead");
16162 }
16163 self.seq += 1;
16165 self.session
16166 .submit_intent(Intent::Dodge {
16167 entity_id: self.state.entity_id,
16168 forward,
16169 strafe,
16170 seq: self.seq,
16171 })
16172 .await?;
16173 self.state.intents_sent += 1;
16174 self.state.push_log("Dodge!");
16175 self.state.push_audio(crate::social::AudioCue::CombatDodge);
16176 Ok(())
16177 }
16178
16179 pub async fn lunge(&mut self) -> anyhow::Result<()> {
16180 if !self.state.is_alive() {
16181 anyhow::bail!("you are dead");
16182 }
16183 let (forward, strafe) = self.last_move_axes();
16184 self.seq += 1;
16185 self.session
16186 .submit_intent(Intent::Lunge {
16187 entity_id: self.state.entity_id,
16188 forward,
16189 strafe,
16190 seq: self.seq,
16191 })
16192 .await?;
16193 self.state.intents_sent += 1;
16194 self.state.push_log("Lunge!");
16195 Ok(())
16196 }
16197
16198 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16199 if !self.state.is_alive() {
16200 anyhow::bail!("you are dead");
16201 }
16202 self.seq += 1;
16203 self.session
16204 .submit_intent(Intent::DirectionalJump {
16205 entity_id: self.state.entity_id,
16206 forward,
16207 strafe,
16208 seq: self.seq,
16209 })
16210 .await?;
16211 self.state.intents_sent += 1;
16212 self.state.push_log("Jump!");
16213 Ok(())
16214 }
16215
16216 pub fn last_move_axes(&self) -> (f32, f32) {
16218 (self.last_move_forward, self.last_move_strafe)
16219 }
16220
16221 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
16222 if !self.state.is_alive() {
16223 anyhow::bail!("you are dead");
16224 }
16225 self.seq += 1;
16226 self.session
16227 .submit_intent(Intent::Block {
16228 entity_id: self.state.entity_id,
16229 enabled,
16230 seq: self.seq,
16231 })
16232 .await?;
16233 self.state.intents_sent += 1;
16234 if enabled {
16235 self.state.push_log("Blocking");
16236 self.state.push_audio(crate::social::AudioCue::CombatBlock);
16237 }
16238 Ok(())
16239 }
16240
16241 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
16242 if !self.state.is_alive() {
16243 anyhow::bail!("you are dead");
16244 }
16245 self.seq += 1;
16246 self.session
16247 .submit_intent(Intent::EquipMainhand {
16248 entity_id: self.state.entity_id,
16249 template_id,
16250 instance_id: None,
16251 seq: self.seq,
16252 })
16253 .await?;
16254 self.state.intents_sent += 1;
16255 Ok(())
16256 }
16257
16258 pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
16260 let idx = self.state.equip_menu_index;
16261 let slots = equip_paperdoll_rows(&self.state);
16262 let Some(row) = slots.get(idx) else {
16263 return Ok(());
16264 };
16265 match row {
16266 EquipPaperdollRow::Body { slot, filled } => {
16267 if *filled {
16268 self.equip_worn(*slot, None).await
16269 } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
16270 self.equip_worn(*slot, Some(inst)).await
16271 } else {
16272 self.state
16273 .push_log(format!("No item for {}", body_slot_label(*slot)));
16274 Ok(())
16275 }
16276 }
16277 EquipPaperdollRow::Mainhand { filled } => {
16278 if *filled {
16279 self.unequip_mainhand().await
16280 } else if let Some(tid) = first_inventory_weapon(&self.state) {
16281 self.equip_mainhand(Some(tid)).await
16282 } else {
16283 self.state.push_log("No weapon in inventory".to_string());
16284 Ok(())
16285 }
16286 }
16287 EquipPaperdollRow::Offhand { filled, locked } => {
16288 if *locked {
16289 self.state
16290 .push_log("Offhand locked — two-handed weapon equipped".to_string());
16291 Ok(())
16292 } else if *filled {
16293 self.unequip_offhand().await
16294 } else if let Some(tid) = first_inventory_offhand(&self.state) {
16295 self.equip_offhand(Some(tid)).await
16296 } else {
16297 self.state
16298 .push_log("No offhand item in inventory".to_string());
16299 Ok(())
16300 }
16301 }
16302 }
16303 }
16304
16305 pub async fn say(
16306 &mut self,
16307 channel: flatland_protocol::ChatChannel,
16308 text: &str,
16309 ) -> anyhow::Result<()> {
16310 self.say_to(channel, text, None).await
16311 }
16312
16313 pub async fn say_to(
16314 &mut self,
16315 channel: flatland_protocol::ChatChannel,
16316 text: &str,
16317 to_entity: Option<EntityId>,
16318 ) -> anyhow::Result<()> {
16319 self.seq += 1;
16320 self.session
16321 .submit_intent(Intent::Say {
16322 entity_id: self.state.entity_id,
16323 channel,
16324 text: text.to_string(),
16325 to_entity,
16326 seq: self.seq,
16327 })
16328 .await?;
16329 self.state.intents_sent += 1;
16330 Ok(())
16331 }
16332
16333 pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
16334 let Some(peer) = self.state.player_verbs.target_entity else {
16335 return Ok(());
16336 };
16337 let label = self.state.player_verbs.target_label.clone();
16338 let choice = crate::social::PlayerVerbState::options()
16339 .get(self.state.player_verbs.index)
16340 .copied()
16341 .unwrap_or("Whisper");
16342 self.state.player_verbs.close();
16343 match choice {
16344 "Trade" => {
16345 self.seq += 1;
16348 self.session
16349 .submit_intent(Intent::TradeRequest {
16350 entity_id: self.state.entity_id,
16351 peer_entity_id: peer,
16352 seq: self.seq,
16353 })
16354 .await?;
16355 self.state.intents_sent += 1;
16356 self.state.social_chat.push_system(format!(
16357 "Trade request sent to {label} — waiting for accept"
16358 ));
16359 }
16360 "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
16361 _ => self.state.social_chat.focus_nearby(),
16362 }
16363 Ok(())
16364 }
16365
16366 pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
16367 let Some(pending) = self.state.social_chat.pending_trade.take() else {
16368 return Ok(());
16369 };
16370 self.seq += 1;
16371 self.session
16372 .submit_intent(Intent::TradeRespond {
16373 entity_id: self.state.entity_id,
16374 peer_entity_id: pending.from_entity,
16375 accept,
16376 seq: self.seq,
16377 })
16378 .await?;
16379 self.state.intents_sent += 1;
16380 if accept {
16381 self.state
16382 .social_chat
16383 .push_system(format!("Accepted trade with {}", pending.from_name));
16384 } else {
16385 self.state
16386 .social_chat
16387 .push_system(format!("Declined trade with {}", pending.from_name));
16388 }
16389 Ok(())
16390 }
16391
16392 pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
16393 let text = self.state.social_chat.buffer.trim().to_string();
16394 if text.is_empty() {
16395 return Ok(());
16396 }
16397 self.state.social_chat.buffer.clear();
16398 if crate::social::is_chat_slash_line(&text) {
16399 match crate::social::parse_chat_slash(&text) {
16400 Some(cmd) => return self.apply_chat_slash(cmd).await,
16401 None => {
16402 self.state.social_chat.push_system(format!(
16403 "Unknown command — {}",
16404 crate::social::chat_slash_help_text()
16405 ));
16406 return Ok(());
16407 }
16408 }
16409 }
16410 let thread = self.state.social_chat.thread;
16411 let channel = thread.channel();
16412 let to = thread.to_entity();
16413 if let Some(peer) = to {
16414 let label = self.state.social_chat.peer_label.clone();
16415 self.state
16416 .social_chat
16417 .remember_whisper_peer(peer, &label, channel);
16418 }
16419 self.say_to(channel, &text, to).await
16420 }
16421
16422 async fn apply_chat_slash(
16423 &mut self,
16424 cmd: crate::social::ChatSlashCommand,
16425 ) -> anyhow::Result<()> {
16426 use crate::social::{chat_slash_help_text, ChatSlashCommand};
16427 match cmd {
16428 ChatSlashCommand::Help => {
16429 self.state
16430 .social_chat
16431 .push_system(chat_slash_help_text().to_string());
16432 Ok(())
16433 }
16434 ChatSlashCommand::Nearby { message } => {
16435 self.state.social_chat.focus_nearby();
16436 self.state
16437 .social_chat
16438 .push_system("Nearby speech — everyone close can hear");
16439 if let Some(msg) = message {
16440 self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
16441 .await
16442 } else {
16443 Ok(())
16444 }
16445 }
16446 ChatSlashCommand::Reply { message } => {
16447 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16448 self.state
16449 .social_chat
16450 .push_system("No one to reply to — wait for a whisper, or /whisper Name");
16451 return Ok(());
16452 };
16453 let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
16454 self.state
16455 .social_chat
16456 .set_whisper_thread(peer.entity_id, &peer.label, stone);
16457 self.state.social_chat.push_system(format!(
16458 "Replying to {} — type and Enter · /nearby",
16459 peer.label
16460 ));
16461 if let Some(msg) = message {
16462 self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
16463 } else {
16464 Ok(())
16465 }
16466 }
16467 ChatSlashCommand::Whisper { name, message } => {
16468 let (peer_id, label, stone) = if let Some(name) = name {
16469 match self.resolve_whisper_target(&name) {
16470 Ok(t) => t,
16471 Err(err) => {
16472 self.state.social_chat.push_system(err);
16473 return Ok(());
16474 }
16475 }
16476 } else {
16477 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16478 self.state.social_chat.push_system(
16479 "Usage: /whisper Name [message] · or /reply after someone whispers you",
16480 );
16481 return Ok(());
16482 };
16483 (
16484 peer.entity_id,
16485 peer.label,
16486 peer.channel == flatland_protocol::ChatChannel::WhisperStone,
16487 )
16488 };
16489 self.state
16490 .social_chat
16491 .set_whisper_thread(peer_id, &label, stone);
16492 let channel = if stone {
16493 flatland_protocol::ChatChannel::WhisperStone
16494 } else {
16495 flatland_protocol::ChatChannel::Whisper
16496 };
16497 if let Some(msg) = message {
16498 self.state
16499 .social_chat
16500 .push_system(format!("Whisper → {label}"));
16501 self.say_to(channel, &msg, Some(peer_id)).await
16502 } else {
16503 self.state.social_chat.push_system(format!(
16504 "Whispering {label} — type and Enter · Esc / /nearby cancels"
16505 ));
16506 Ok(())
16507 }
16508 }
16509 }
16510 }
16511
16512 fn resolve_whisper_target(&self, name: &str) -> Result<(EntityId, String, bool), String> {
16514 let needle = name.trim().to_ascii_lowercase();
16515 if needle.is_empty() {
16516 return Err("Usage: /whisper Name [message]".into());
16517 }
16518 let mut candidates: Vec<(EntityId, String)> = self
16519 .state
16520 .entities
16521 .iter()
16522 .filter(|e| e.id != self.state.entity_id)
16523 .filter(|e| !e.label.trim().is_empty())
16524 .filter(|e| e.vitals.is_some())
16525 .filter(|e| !self.state.npcs.iter().any(|n| n.id == e.id.to_string()))
16526 .filter(|e| !self.state.hired_workers.iter().any(|w| w.entity_id == e.id))
16527 .map(|e| (e.id, e.label.clone()))
16528 .collect();
16529
16530 if let Some(last) = &self.state.social_chat.last_whisper_peer {
16532 if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
16533 candidates.push((last.entity_id, last.label.clone()));
16534 }
16535 }
16536
16537 let exact: Vec<_> = candidates
16538 .iter()
16539 .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
16540 .cloned()
16541 .collect();
16542 let pool = if exact.len() == 1 {
16543 exact
16544 } else if exact.len() > 1 {
16545 return Err(format!(
16546 "Several players named '{name}' nearby — move closer and try again"
16547 ));
16548 } else {
16549 let starts: Vec<_> = candidates
16550 .iter()
16551 .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
16552 .cloned()
16553 .collect();
16554 if starts.len() == 1 {
16555 starts
16556 } else if starts.len() > 1 {
16557 let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
16558 return Err(format!(
16559 "Ambiguous name '{name}' — matches: {}",
16560 names.join(", ")
16561 ));
16562 } else {
16563 let contains: Vec<_> = candidates
16564 .iter()
16565 .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
16566 .cloned()
16567 .collect();
16568 if contains.len() == 1 {
16569 contains
16570 } else if contains.is_empty() {
16571 return Err(format!(
16572 "No player matching '{name}' in range — get closer or check the spelling"
16573 ));
16574 } else {
16575 let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
16576 return Err(format!(
16577 "Ambiguous name '{name}' — matches: {}",
16578 names.join(", ")
16579 ));
16580 }
16581 }
16582 };
16583
16584 let (id, label) = pool.into_iter().next().unwrap();
16585 let stone = self
16586 .state
16587 .social_chat
16588 .last_whisper_peer
16589 .as_ref()
16590 .is_some_and(|p| {
16591 p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone
16592 });
16593 Ok((id, label, stone))
16594 }
16595
16596 pub async fn trade_present_selected(
16597 &mut self,
16598 item_instance_id: uuid::Uuid,
16599 ) -> anyhow::Result<()> {
16600 self.trade_present_quantity(item_instance_id, None).await
16601 }
16602
16603 pub async fn trade_present_quantity(
16604 &mut self,
16605 item_instance_id: uuid::Uuid,
16606 quantity: Option<u32>,
16607 ) -> anyhow::Result<()> {
16608 self.seq += 1;
16609 self.session
16610 .submit_intent(Intent::TradePresent {
16611 entity_id: self.state.entity_id,
16612 item_instance_id,
16613 quantity,
16614 seq: self.seq,
16615 })
16616 .await?;
16617 self.state.intents_sent += 1;
16618 self.state.trade_ui.qty_entry = None;
16619 self.state.trade_ui.picking_inventory = false;
16620 Ok(())
16621 }
16622
16623 pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
16625 if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
16626 let qty = self.state.trade_ui.present_quantity();
16627 return self
16628 .trade_present_quantity(entry.item_instance_id, qty)
16629 .await;
16630 }
16631 if !self.state.trade_ui.picking_inventory {
16632 return Ok(());
16633 }
16634 let stacks = self.state.trade_presentable_stacks();
16635 let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
16636 return Ok(());
16637 };
16638 let Some(id) = stack.item_instance_id else {
16639 return Ok(());
16640 };
16641 let label = stack
16642 .display_name
16643 .clone()
16644 .unwrap_or_else(|| stack.template_id.clone());
16645 if stack.quantity <= 1 {
16646 self.trade_present_quantity(id, Some(1)).await
16647 } else {
16648 self.state
16649 .trade_ui
16650 .begin_qty_entry(id, label, stack.quantity);
16651 Ok(())
16652 }
16653 }
16654
16655 pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
16656 self.seq += 1;
16657 self.session
16658 .submit_intent(Intent::TradeSetReady {
16659 entity_id: self.state.entity_id,
16660 ready,
16661 seq: self.seq,
16662 })
16663 .await?;
16664 self.state.intents_sent += 1;
16665 Ok(())
16666 }
16667
16668 pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
16669 self.seq += 1;
16670 self.session
16671 .submit_intent(Intent::TradeCancel {
16672 entity_id: self.state.entity_id,
16673 seq: self.seq,
16674 })
16675 .await?;
16676 self.state.intents_sent += 1;
16677 self.state.trade_ui.close();
16678 Ok(())
16679 }
16680
16681 pub async fn destroy_whisper_stone(
16682 &mut self,
16683 item_instance_id: uuid::Uuid,
16684 ) -> anyhow::Result<()> {
16685 self.seq += 1;
16686 self.session
16687 .submit_intent(Intent::DestroyWhisperStone {
16688 entity_id: self.state.entity_id,
16689 item_instance_id,
16690 seq: self.seq,
16691 })
16692 .await?;
16693 self.state.intents_sent += 1;
16694 Ok(())
16695 }
16696
16697 pub async fn stop(&mut self) -> anyhow::Result<()> {
16698 self.seq += 1;
16699 self.session
16700 .submit_intent(Intent::Stop {
16701 entity_id: self.state.entity_id,
16702 seq: self.seq,
16703 })
16704 .await?;
16705 self.state.intents_sent += 1;
16706 Ok(())
16707 }
16708
16709 pub fn disconnect(&self) {
16710 self.session.disconnect();
16711 }
16712}
16713
16714fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
16715 let dx = ax - bx;
16716 let dy = ay - by;
16717 (dx * dx + dy * dy).sqrt()
16718}
16719
16720#[cfg(test)]
16721mod tests {
16722 use std::collections::BTreeMap;
16723
16724 use super::*;
16725 use flatland_protocol::{
16726 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
16727 };
16728
16729 fn sample_state() -> GameState {
16730 let mut state = GameState {
16731 session_id: 1,
16732 entity_id: 1,
16733 character_id: None,
16734 tick: 0,
16735 chunk_rev: 0,
16736 content_rev: 0,
16737 publish_rev: 0,
16738 entities: vec![EntityState {
16739 id: 1,
16740 label: "You".into(),
16741 transform: Transform {
16742 position: WorldCoord::surface(128.0, 128.0),
16743 yaw: 0.0,
16744 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
16745 },
16746 vitals: None,
16747 attributes: None,
16748 skills: None,
16749 inside_building: None,
16750 tile_id: None,
16751 paperdoll_ref: None,
16752 draw_scale: 1.0,
16753 presentation_state: None,
16754 sprite_mode: None,
16755 progression_xp: None,
16756 combat_cues: vec![],
16757 statuses: vec![],
16758 }],
16759 player: None,
16760 resource_nodes: vec![ResourceNodeView {
16761 id: "oak-1".into(),
16762 label: "Oak".into(),
16763 x: 130.0,
16764 y: 128.0,
16765 z: 0.0,
16766 item_template: "oak_log".into(),
16767 state: ResourceNodeState::Available,
16768 blocking: true,
16769 blocking_radius_m: 0.8,
16770 harvest_off: false,
16771 tile_id: None,
16772 yaw: 0.0,
16773 pitch: 0.0,
16774 roll: 0.0,
16775 draw_scale: 1.0,
16776 sprite_mode: None,
16777 growth_progress: None,
16778 presentation_state: None,
16779 channel_start_tick: None,
16780 channel_end_tick: None,
16781 harvest_drop_templates: vec![],
16782 }],
16783 ground_drops: vec![],
16784 placed_containers: vec![],
16785 buildings: vec![BuildingView {
16786 id: "broker-hut".into(),
16787 label: "Broker".into(),
16788 x: 148.0,
16789 y: 118.0,
16790 width_m: 8.0,
16791 depth_m: 6.0,
16792 interior_blueprint: Some("broker_hut".into()),
16793 tags: vec![],
16794 market_boundary_zone_ids: vec![],
16795 market_max_volume: None,
16796 wall_set: None,
16797 roof_set: None,
16798 }],
16799 doors: vec![flatland_protocol::DoorView {
16800 id: "door-1".into(),
16801 building_id: "broker-hut".into(),
16802 x: 148.0,
16803 y: 118.0,
16804 open: false,
16805 portal: Some("front".into()),
16806 locked: false,
16807 accessible: true,
16808 lock_id: None,
16809 }],
16810 interior_map: None,
16811 npcs: vec![],
16812 blueprints: vec![],
16813 building_materials: vec![],
16814 world_x0: 0.0,
16815 world_y0: 0.0,
16816 world_width_m: 256.0,
16817 world_height_m: 256.0,
16818 terrain_zones: Vec::new(),
16819 z_platforms: Vec::new(),
16820 z_transitions: Vec::new(),
16821 z_bands_outdoor_backup: None,
16822 world_clock: flatland_protocol::WorldClock::default(),
16823 inventory: std::collections::HashMap::new(),
16824 inventory_hints: std::collections::HashMap::new(),
16825 item_catalog: std::collections::HashMap::new(),
16826 logs: VecDeque::new(),
16827 intents_sent: 0,
16828 ticks_received: 0,
16829 connected: true,
16830 disconnect_reason: None,
16831 show_stats: false,
16832 hud_log_hidden: false,
16833 show_equip_menu: false,
16834 equip_menu_index: 0,
16835 show_craft_menu: false,
16836 show_plot_build_menu: false,
16837 plot_build_focus_wall: true,
16838 plot_build_wall_index: 0,
16839 plot_build_roof_index: 0,
16840 craft_menu_index: 0,
16841 craft_batch_quantity: 1,
16842 craft_tab: CraftTab::Ready,
16843 craft_filter: String::new(),
16844 craft_filter_focused: false,
16845 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
16846 show_shop_menu: false,
16847 shop_catalog: None,
16848 bank_panel: None,
16849 bank_menu_index: 0,
16850 bank_ui_mode: BankUiMode::Menu,
16851 storage_panel: None,
16852 market_panel: None,
16853 market_menu_index: 0,
16854 market_filter: String::new(),
16855 market_filter_focused: false,
16856 market_category_filter: None,
16857 market_buy_confirm: None,
16858 market_ui_mode: MarketUiMode::Browse,
16859 storage_menu_index: 0,
16860 storage_ui_mode: StorageUiMode::Menu,
16861 shop_tab: ShopTab::default(),
16862 shop_menu_index: 0,
16863 shop_quantity: 1,
16864 shop_trade_log: VecDeque::new(),
16865 show_npc_verb_menu: false,
16866 npc_verb_target: None,
16867 npc_verb_index: 0,
16868 npc_verb_notice: None,
16869 player_verbs: crate::social::PlayerVerbState::default(),
16870 social_chat: crate::social::SocialChatState::default(),
16871 trade_ui: crate::social::TradeUiState::default(),
16872 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
16873 show_npc_chat: false,
16874 npc_chat: None,
16875 show_inventory_menu: false,
16876 inventory_menu_index: 0,
16877 inventory_tab: InventoryTab::OnPerson,
16878 inventory_filter: String::new(),
16879 inventory_filter_focused: false,
16880 show_move_picker: false,
16881 show_rename_prompt: false,
16882 rename_plot_id: None,
16883 highlighted_plot_id: None,
16884 show_worker_rename: false,
16885 rename_buffer: String::new(),
16886 move_picker_index: 0,
16887 move_picker: None,
16888 show_grant_picker: false,
16889 grant_picker_index: 0,
16890 grant_picker: None,
16891 show_destroy_picker: false,
16892 destroy_confirm_pending: false,
16893 destroy_picker: None,
16894 combat_target: None,
16895 combat_target_label: None,
16896 ground_target: None,
16897 combat_fx: Vec::new(),
16898 ground_hazards: Vec::new(),
16899 property_zones: Vec::new(),
16900 tax_zones: Vec::new(),
16901 growth_zones: Vec::new(),
16902 biome_zones: Vec::new(),
16903 terrain_kind_nav: Vec::new(),
16904 property_plots: Vec::new(),
16905 property_plot_settings: None,
16906 claim_mode: None,
16907 relocate_mode: None,
16908 sell_plot_confirm: None,
16909 sell_plot_armed_at: None,
16910 show_plant_menu: false,
16911 plant_menu_index: 0,
16912 show_farm_access: false,
16913 farm_access_name_draft: String::new(),
16914 farm_access_discount_bps: 0,
16915 farm_access_index: 0,
16916 plant_quantity: 1,
16917 in_combat: false,
16918 auto_attack: true,
16919 combat_has_los: false,
16920 attack_cd_ticks: 0,
16921 gcd_ticks: 0,
16922 weapon_ability_id: "unarmed".into(),
16923 mainhand_template_id: None,
16924 mainhand_label: None,
16925 mainhand_instance_id: None,
16926 offhand_template_id: None,
16927 offhand_label: None,
16928 offhand_instance_id: None,
16929 mainhand_hand_slots: 1,
16930 defense: None,
16931 worn: BTreeMap::new(),
16932 carry_mass: 0.0,
16933 carry_mass_max: 0.0,
16934 encumbrance: flatland_protocol::EncumbranceState::Light,
16935 move_speed_mps: 0.0,
16936 move_speed_mult: 0.0,
16937 inventory_stacks: Vec::new(),
16938 keychain_stacks: Vec::new(),
16939 whisper_pouch_stacks: Vec::new(),
16940 combat_target_detail: None,
16941 statuses: Vec::new(),
16942 cast_progress: None,
16943 timed_channel: None,
16944 plot_build_offer: None,
16945 ability_cooldowns: Vec::new(),
16946 blocking_active: false,
16947 max_target_slots: 1,
16948 combat_slots: Vec::new(),
16949 rotation_presets: Vec::new(),
16950 known_abilities: Vec::new(),
16951 ability_meta: std::collections::HashMap::new(),
16952 ability_mastery: std::collections::HashMap::new(),
16953 hotbar: vec![None; 9],
16954 max_abilities_per_rotation: 0,
16955 show_loadout_menu: false,
16956 show_keychain_menu: false,
16957 keychain_menu_index: 0,
16958 show_rotation_editor: false,
16959 loadout_menu_index: 0,
16960 loadout_hotbar_slot: 1,
16961 loadout_ability_index: 0,
16962 loadout_focus_presets: false,
16963 rotation_editor: RotationEditorState::default(),
16964 harvest_in_progress: false,
16965 harvest_started_at: None,
16966 pending_craft_ack: None,
16967 craft_channel_blueprint_id: None,
16968 pending_worker_job_ack: None,
16969 attending_worker_instance_id: None,
16970 quest_log: Vec::new(),
16971 interactables: Vec::new(),
16972 ledger: None,
16973 career: None,
16974 character_sheet_tab: CharacterSheetTab::Character,
16975 ledger_period: LedgerPeriod::Day,
16976 show_quest_offer: false,
16977 pending_quest_offers: Vec::new(),
16978 quest_offer_index: 0,
16979 show_quest_menu: false,
16980 quest_menu_index: 0,
16981 quest_withdraw_confirm: false,
16982 hired_workers: Vec::new(),
16983 show_workers_menu: false,
16984 workers_menu_index: 0,
16985 worker_dismiss_confirmation: None,
16986 workers_menu_compact: false,
16987 worker_step_display: BTreeMap::new(),
16988 worker_error_display: BTreeMap::new(),
16989 worker_health_ring_until: BTreeMap::new(),
16990 pending_worker_hire_since: None,
16991 show_worker_give_picker: false,
16992 worker_give_picker_index: 0,
16993 worker_give_picker: None,
16994 show_worker_give_target_picker: false,
16995 worker_give_target_picker_index: 0,
16996 worker_give_target_picker: None,
16997 show_worker_take_picker: false,
16998 worker_take_picker_index: 0,
16999 worker_take_picker: None,
17000 show_worker_teach_picker: false,
17001 worker_teach_picker_index: 0,
17002 worker_teach_picker: None,
17003 worker_route_editor: None,
17004 progression_curve: None,
17005 };
17006 state.player = state.entities.first().cloned();
17007 state
17008 }
17009
17010 #[test]
17011 fn template_display_name_uses_item_catalog_for_uuid_ids() {
17012 let mut state = sample_state();
17013 let id = "7ac81ed5-e3d3-4b7f-a7fb-11d6c34a0995";
17014 assert_eq!(state.template_display_name(id), "Unknown item");
17015 state.item_catalog.insert(
17016 id.into(),
17017 ItemCatalogEntryView {
17018 template_id: id.into(),
17019 display_name: "Emerald".into(),
17020 category: "resource".into(),
17021 seed_for: None,
17022 },
17023 );
17024 assert_eq!(state.template_display_name(id), "Emerald");
17025 }
17026
17027 #[test]
17028 fn whisper_cancels_when_peer_walks_out_of_range() {
17029 let mut state = sample_state();
17030 state.player = state.entities.first().cloned();
17031 let mut peer = state.entities[0].clone();
17032 peer.id = 2;
17033 peer.label = "Ada".into();
17034 peer.transform.position = WorldCoord::surface(129.0, 128.0); state.entities.push(peer.clone());
17036 state.social_chat.focus_whisper(2, "Ada");
17037 state.refresh_whisper_range();
17038 assert!(matches!(
17039 state.social_chat.thread,
17040 crate::social::ChatThreadKind::Whisper { peer: 2 }
17041 ));
17042
17043 peer.transform.position = WorldCoord::surface(132.0, 128.0); state.entities[1] = peer;
17045 state.refresh_whisper_range();
17046 assert_eq!(
17047 state.social_chat.thread,
17048 crate::social::ChatThreadKind::Nearby
17049 );
17050 assert!(!state.social_chat.input_focused);
17051 }
17052
17053 #[test]
17054 fn probe_use_world_hired_worker_manage() {
17055 let mut state = sample_state();
17056 state
17057 .hired_workers
17058 .push(flatland_protocol::HiredWorkerView {
17059 instance_id: "worker-1".into(),
17060 entity_id: 42,
17061 def_id: "worker_laborer".into(),
17062 label: "Sam".into(),
17063 x: 129.0,
17064 y: 128.0,
17065 z: 0.0,
17066 mode: flatland_protocol::WorkerModeView::JobLoop,
17067 state: flatland_protocol::WorkerStateView::Working,
17068 step_label: "cultivate".into(),
17069 vitals: flatland_protocol::WorkerVitalsSummary {
17070 health_pct: 100.0,
17071 stamina_pct: 100.0,
17072 mana_pct: 100.0,
17073 hunger_pct: 100.0,
17074 thirst_pct: 100.0,
17075 },
17076 carry_pct: 0.0,
17077 last_error: None,
17078 wage_copper_per_interval: 1,
17079 effective_wage_copper: 1,
17080 wage_meters_walked: 0.0,
17081 lodging_container_id: None,
17082 route: None,
17083 route_stop_index: None,
17084 known_blueprint_ids: Vec::new(),
17085 level: 1,
17086 worker_xp: 0.0,
17087 inventory: Vec::new(),
17088 equipment: flatland_protocol::WorkerEquipmentView::default(),
17089 issue_hint: None,
17090 });
17091 let probe = state.probe_use_world();
17092 let primary = probe.primary.expect("primary");
17093 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17094 assert_eq!(primary.id, "worker-1");
17095 assert!(primary.hint_line().contains("Manage"));
17096 assert!(primary.hint_line().contains("Sam"));
17097 assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17098 }
17099
17100 fn sample_hired_worker(x: f32, y: f32) -> flatland_protocol::HiredWorkerView {
17101 flatland_protocol::HiredWorkerView {
17102 instance_id: "worker-1".into(),
17103 entity_id: 42,
17104 def_id: "worker_laborer".into(),
17105 label: "Sam".into(),
17106 x,
17107 y,
17108 z: 0.0,
17109 mode: flatland_protocol::WorkerModeView::JobLoop,
17110 state: flatland_protocol::WorkerStateView::Working,
17111 step_label: "follow".into(),
17112 vitals: flatland_protocol::WorkerVitalsSummary {
17113 health_pct: 100.0,
17114 stamina_pct: 100.0,
17115 mana_pct: 100.0,
17116 hunger_pct: 100.0,
17117 thirst_pct: 100.0,
17118 },
17119 carry_pct: 0.0,
17120 last_error: None,
17121 wage_copper_per_interval: 1,
17122 effective_wage_copper: 1,
17123 wage_meters_walked: 0.0,
17124 lodging_container_id: None,
17125 route: None,
17126 route_stop_index: None,
17127 known_blueprint_ids: Vec::new(),
17128 level: 1,
17129 worker_xp: 0.0,
17130 inventory: Vec::new(),
17131 equipment: flatland_protocol::WorkerEquipmentView::default(),
17132 issue_hint: None,
17133 }
17134 }
17135
17136 #[test]
17137 fn probe_harvest_beats_closer_hired_worker() {
17138 let mut state = sample_state();
17139 state.resource_nodes[0].x = 129.0;
17140 state.resource_nodes[0].y = 128.0;
17141 state.hired_workers.push(sample_hired_worker(128.2, 128.0));
17142 let probe = state.probe_use_world();
17143 let primary = probe.primary.expect("primary");
17144 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17145 assert_eq!(primary.id, "oak-1");
17146 assert!(state.harvestable_node_in_range());
17147 assert_eq!(
17148 state.nearest_interact_target().as_deref(),
17149 Some("worker-1"),
17150 "harvest is not Interact — worker remains the interact target"
17151 );
17152 }
17153
17154 #[test]
17155 fn probe_door_beats_closer_hired_worker() {
17156 let mut state = sample_state();
17157 state.doors[0].x = 129.2;
17158 state.doors[0].y = 128.0;
17159 state.hired_workers.push(sample_hired_worker(128.3, 128.0));
17160 let probe = state.probe_use_world();
17161 let primary = probe.primary.expect("primary");
17162 assert!(
17163 matches!(
17164 primary.kind,
17165 crate::UseWorldKind::EnterDoor
17166 | crate::UseWorldKind::OpenDoor
17167 | crate::UseWorldKind::CloseDoor
17168 | crate::UseWorldKind::ExitDoor
17169 ),
17170 "door should win over closer worker, got {:?}",
17171 primary.kind
17172 );
17173 assert_eq!(primary.id, "door-1");
17174 assert_eq!(state.nearest_interact_target().as_deref(), Some("door-1"));
17175 }
17176
17177 #[test]
17178 fn probe_indoor_exit_door_beats_lodging_chest_pickup() {
17179 let mut state = sample_state();
17182 state.entities[0].inside_building = Some("player_house".into());
17183 state.entities[0].transform.position = WorldCoord::surface(5.0, 2.0);
17184 state.player = state.entities.first().cloned();
17185 state.buildings = vec![BuildingView {
17186 id: "player_house".into(),
17187 label: "MadSin's house".into(),
17188 x: 100.0,
17189 y: 100.0,
17190 width_m: 10.0,
17191 depth_m: 8.0,
17192 interior_blueprint: Some("player_house".into()),
17193 tags: vec!["player_built".into()],
17194 market_boundary_zone_ids: vec![],
17195 market_max_volume: None,
17196 wall_set: None,
17197 roof_set: None,
17198 }];
17199 state.doors = vec![flatland_protocol::DoorView {
17200 id: "house_exit".into(),
17201 building_id: "player_house".into(),
17202 x: 5.0,
17203 y: 1.0,
17204 open: true,
17205 portal: Some("front".into()),
17206 locked: false,
17207 accessible: true,
17208 lock_id: None,
17209 }];
17210 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17211 id: "lodging_bed".into(),
17212 template_id: "camp_bed".into(),
17213 display_name: "Camp bed".into(),
17214 x: 5.5,
17215 y: 2.4,
17216 z: 0.0,
17217 locked: false,
17218 accessible: true,
17219 owner_character_id: None,
17220 contents: vec![],
17221 lock_id: None,
17222 capacity_volume: None,
17223 item_instance_id: Some(uuid::Uuid::from_u128(99)),
17224 tile_id: None,
17225 worker_lodging_capacity: Some(1),
17226 blocking: false,
17227 blocking_radius_m: 0.0,
17228 building_id: Some("player_house".into()),
17229 }];
17230 let mut worker = sample_hired_worker(40.0, 40.0);
17232 worker.lodging_container_id = Some("lodging_bed".into());
17233 state.hired_workers.push(worker);
17234
17235 let probe = state.probe_use_world();
17236 let primary = probe.primary.expect("primary");
17237 assert!(
17238 matches!(
17239 primary.kind,
17240 crate::UseWorldKind::ExitDoor
17241 | crate::UseWorldKind::OpenDoor
17242 | crate::UseWorldKind::CloseDoor
17243 | crate::UseWorldKind::EnterDoor
17244 ),
17245 "indoor exit must beat lodging ChestPickup, got {:?}",
17246 primary.kind
17247 );
17248 assert_eq!(primary.id, "house_exit");
17249 assert_eq!(primary.kind.cascade_stage(), 0);
17250 assert!(
17251 probe
17252 .candidates
17253 .iter()
17254 .any(|c| c.kind == crate::UseWorldKind::ChestPickup && c.in_range),
17255 "lodging bed should still be an in-range chest candidate"
17256 );
17257 assert_eq!(
17258 state.nearest_interact_target().as_deref(),
17259 Some("house_exit"),
17260 "use_nearest interact path should target the door"
17261 );
17262 assert!(state.lodging_is_occupied("lodging_bed"));
17263 }
17264
17265 #[test]
17266 fn probe_worker_when_no_resource_or_door_in_range() {
17267 let mut state = sample_state();
17268 state.hired_workers.push(sample_hired_worker(129.0, 128.0));
17270 let probe = state.probe_use_world();
17271 let primary = probe.primary.expect("primary");
17272 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17273 assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17274 assert!(!state.harvestable_node_in_range());
17275 }
17276
17277 #[test]
17278 fn market_clerk_verb_options_include_market() {
17279 let mut state = sample_state();
17280 state.npcs.push(flatland_protocol::NpcView {
17281 id: "mira_market".into(),
17282 label: "Mira".into(),
17283 role: "market_clerk".into(),
17284 x: 129.0,
17285 y: 128.0,
17286 building_id: Some("town_market".into()),
17287 entity_id: None,
17288 life_state: None,
17289 hp_pct: None,
17290 can_trade: false,
17291 buy_templates: vec![],
17292 tile_id: None,
17293 behavior_state: None,
17294 presentation_state: None,
17295 sprite_mode: None,
17296 paperdoll_ref: None,
17297 draw_scale: 1.0,
17298 yaw: None,
17299 perception_fov_deg: None,
17300 perception_sight_m: None,
17301 perception_hear_m: None,
17302 quest_verbs: Vec::new(),
17303 });
17304 state.npc_verb_target = Some("mira_market".into());
17305 assert_eq!(
17306 state
17307 .npc_verb_options()
17308 .iter()
17309 .map(|v| v.label.as_str())
17310 .collect::<Vec<_>>(),
17311 vec!["Market", "Talk"]
17312 );
17313 }
17314
17315 #[test]
17316 fn butcher_verb_options_include_turn_in_for_give_item() {
17317 let mut state = sample_state();
17318 state.npcs.push(flatland_protocol::NpcView {
17319 id: "town_butcher_1".into(),
17320 label: "Brutus".into(),
17321 role: "butcher".into(),
17322 x: 129.0,
17323 y: 128.0,
17324 building_id: None,
17325 entity_id: None,
17326 life_state: None,
17327 hp_pct: None,
17328 can_trade: true,
17329 buy_templates: vec!["raw_venison".into()],
17330 tile_id: None,
17331 behavior_state: None,
17332 presentation_state: None,
17333 sprite_mode: None,
17334 paperdoll_ref: None,
17335 draw_scale: 1.0,
17336 yaw: None,
17337 perception_fov_deg: None,
17338 perception_sight_m: None,
17339 perception_hear_m: None,
17340 quest_verbs: Vec::new(),
17341 });
17342 state.quest_log.push(flatland_protocol::QuestLogEntry {
17343 quest_id: "deer_threat".into(),
17344 title: "Deer threat".into(),
17345 description: String::new(),
17346 status: flatland_protocol::QuestStatusView::Active,
17347 current_step_id: Some("deliver".into()),
17348 current_step_title: "Deliver venison".into(),
17349 current_step_index: 0,
17350 objectives: vec![flatland_protocol::QuestObjectiveProgress {
17351 label: "Give 3 Raw venison to Brutus".into(),
17352 current: 0,
17353 required: 3,
17354 done: false,
17355 kind: "give_item".into(),
17356 npc_ref: Some("town_butcher_1".into()),
17357 item_template: Some("raw_venison".into()),
17358 blueprint_id: None,
17359 building_id: None,
17360 }],
17361 current_step_reward: flatland_protocol::QuestRewardView::default(),
17362 completion_reward: flatland_protocol::QuestRewardView::default(),
17363 steps: Vec::new(),
17364 is_tracked: true,
17365 can_withdraw: true,
17366 });
17367 state.npc_verb_target = Some("town_butcher_1".into());
17368 assert_eq!(
17369 state
17370 .npc_verb_options()
17371 .iter()
17372 .map(|v| v.label.as_str())
17373 .collect::<Vec<_>>(),
17374 vec!["Turn in: Deer threat", "Talk", "Trade"]
17375 );
17376 }
17377
17378 #[test]
17379 fn ada_verb_options_include_quest_offer() {
17380 let mut state = sample_state();
17381 state.npcs.push(flatland_protocol::NpcView {
17382 id: "ada_broker".into(),
17383 label: "Ada".into(),
17384 role: "broker".into(),
17385 x: 129.0,
17386 y: 128.0,
17387 building_id: None,
17388 entity_id: None,
17389 life_state: None,
17390 hp_pct: None,
17391 can_trade: true,
17392 buy_templates: vec![],
17393 tile_id: None,
17394 behavior_state: None,
17395 presentation_state: None,
17396 sprite_mode: None,
17397 paperdoll_ref: Some("ada_broker".into()),
17398 draw_scale: 1.0,
17399 yaw: None,
17400 perception_fov_deg: None,
17401 perception_sight_m: None,
17402 perception_hear_m: None,
17403 quest_verbs: vec![flatland_protocol::NpcQuestVerb {
17404 quest_id: "ada_goblin_hunt".into(),
17405 label: "Ask about goblins".into(),
17406 kind: flatland_protocol::NpcQuestVerb::KIND_OFFER.into(),
17407 }],
17408 });
17409 state.npc_verb_target = Some("ada_broker".into());
17410 assert_eq!(
17411 state
17412 .npc_verb_options()
17413 .iter()
17414 .map(|v| v.label.as_str())
17415 .collect::<Vec<_>>(),
17416 vec!["Ask about goblins", "Talk", "Trade"]
17417 );
17418 }
17419
17420 #[test]
17421 fn market_list_excludes_currency_stacks() {
17422 let mut state = sample_state();
17423 state.inventory_stacks = vec![
17424 flatland_protocol::ItemStack {
17425 template_id: "copper_coin".into(),
17426 quantity: 50,
17427 item_instance_id: Some(uuid::Uuid::from_u128(10)),
17428 display_name: Some("Copper Coin".into()),
17429 ..Default::default()
17430 },
17431 flatland_protocol::ItemStack {
17432 template_id: "oak_log".into(),
17433 quantity: 2,
17434 item_instance_id: Some(uuid::Uuid::from_u128(11)),
17435 display_name: Some("Oak Log".into()),
17436 ..Default::default()
17437 },
17438 flatland_protocol::ItemStack {
17439 template_id: "whisper_stone".into(),
17440 quantity: 1,
17441 item_instance_id: Some(uuid::Uuid::from_u128(12)),
17442 display_name: Some("Whisper Stone".into()),
17443 category: Some("quest".into()),
17444 listable: Some(false),
17445 ..Default::default()
17446 },
17447 ];
17448 let opts = state.market_list_item_options(&MarketListSourceKind::Person);
17449 assert_eq!(opts.len(), 1);
17450 assert!(opts[0].label.contains("Oak"));
17451 }
17452
17453 #[test]
17454 fn market_browse_filters_by_category_and_search() {
17455 let mut state = sample_state();
17456 state.market_panel = Some(flatland_protocol::MarketPanel {
17457 npc_id: "mira_market".into(),
17458 npc_label: "Mira".into(),
17459 building_id: "town_market".into(),
17460 building_label: "Town Market".into(),
17461 used_volume: 0.0,
17462 max_volume: 100.0,
17463 listings: vec![
17464 flatland_protocol::MarketListingView {
17465 listing_id: uuid::Uuid::from_u128(1),
17466 seller_character_id: uuid::Uuid::from_u128(2),
17467 seller_label: "Ada".into(),
17468 hall_building_id: "town_market".into(),
17469 hall_label: "Town Market".into(),
17470 template_id: "oak_log".into(),
17471 display_name: "Oak Log".into(),
17472 category: "resource".into(),
17473 quantity: 3,
17474 unit_price_copper: 10,
17475 line_total_copper: 30,
17476 npc_price: false,
17477 npc_dump_unit_copper: None,
17478 mine: false,
17479 },
17480 flatland_protocol::MarketListingView {
17481 listing_id: uuid::Uuid::from_u128(3),
17482 seller_character_id: uuid::Uuid::from_u128(2),
17483 seller_label: "Ada".into(),
17484 hall_building_id: "town_market".into(),
17485 hall_label: "Town Market".into(),
17486 template_id: "short_sword".into(),
17487 display_name: "Short Sword".into(),
17488 category: "weapon".into(),
17489 quantity: 1,
17490 unit_price_copper: 100,
17491 line_total_copper: 100,
17492 npc_price: false,
17493 npc_dump_unit_copper: None,
17494 mine: false,
17495 },
17496 ],
17497 tax_bps: 0,
17498 tax_flat_copper: 0,
17499 list_vaults: vec![],
17500 });
17501 assert_eq!(state.market_filtered_listing_indices().len(), 2);
17502 state.market_category_filter = Some("Weapons");
17503 let weapons = state.market_filtered_listing_indices();
17504 assert_eq!(weapons.len(), 1);
17505 assert_eq!(
17506 state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
17507 "Short Sword"
17508 );
17509 state.market_category_filter = None;
17510 state.market_filter = "oak".into();
17511 let oak = state.market_filtered_listing_indices();
17512 assert_eq!(oak.len(), 1);
17513 assert_eq!(
17514 state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
17515 "Oak Log"
17516 );
17517 }
17518
17519 #[test]
17520 fn market_list_source_includes_person_and_vaults() {
17521 let mut state = sample_state();
17522 let item_id = uuid::Uuid::from_u128(1);
17523 state.inventory_stacks = vec![flatland_protocol::ItemStack {
17524 template_id: "oak_log".into(),
17525 quantity: 2,
17526 item_instance_id: Some(item_id),
17527 display_name: Some("Oak Log".into()),
17528 ..Default::default()
17529 }];
17530 state.market_panel = Some(flatland_protocol::MarketPanel {
17531 npc_id: "mira_market".into(),
17532 npc_label: "Mira".into(),
17533 building_id: "town_market".into(),
17534 building_label: "Town Market".into(),
17535 used_volume: 0.0,
17536 max_volume: 100.0,
17537 listings: vec![],
17538 tax_bps: 0,
17539 tax_flat_copper: 0,
17540 list_vaults: vec![flatland_protocol::MarketListVault {
17541 building_id: "town_storage".into(),
17542 building_label: "Town Storage".into(),
17543 contents: vec![flatland_protocol::ItemStack {
17544 template_id: "lumber".into(),
17545 quantity: 1,
17546 item_instance_id: Some(uuid::Uuid::from_u128(2)),
17547 display_name: Some("Lumber".into()),
17548 ..Default::default()
17549 }],
17550 }],
17551 });
17552 let sources = state.market_list_source_options();
17553 assert_eq!(sources.len(), 2);
17554 assert!(matches!(sources[0].0, MarketListSourceKind::Person));
17555 assert!(matches!(
17556 sources[1].0,
17557 MarketListSourceKind::TownStorage { .. }
17558 ));
17559 assert!(sources[1].1.contains("Town Storage"));
17560 }
17561
17562 #[test]
17563 fn npc_market_dump_estimate_from_town_storage_vault() {
17564 let mut state = sample_state();
17565 state.market_panel = Some(flatland_protocol::MarketPanel {
17566 npc_id: "mira_market".into(),
17567 npc_label: "Mira".into(),
17568 building_id: "town_market".into(),
17569 building_label: "Town Market".into(),
17570 used_volume: 0.0,
17571 max_volume: 100.0,
17572 listings: vec![],
17573 tax_bps: 0,
17574 tax_flat_copper: 0,
17575 list_vaults: vec![flatland_protocol::MarketListVault {
17576 building_id: "town_storage".into(),
17577 building_label: "Town Storage".into(),
17578 contents: vec![flatland_protocol::ItemStack {
17579 template_id: "lumber".into(),
17580 quantity: 3,
17581 item_instance_id: Some(uuid::Uuid::from_u128(2)),
17582 display_name: Some("Lumber".into()),
17583 base_value_copper: Some(20),
17584 ..Default::default()
17585 }],
17586 }],
17587 });
17588 assert_eq!(
17589 state.npc_market_dump_unit_estimate("lumber"),
17590 Some(9),
17591 "vault stack base_value should enable NPC price estimate"
17592 );
17593 }
17594
17595 #[test]
17596 fn probe_use_world_npc_beats_nearby_loot() {
17597 let mut state = sample_state();
17598 state.npcs.push(flatland_protocol::NpcView {
17599 id: "ada".into(),
17600 label: "Ada".into(),
17601 role: "broker".into(),
17602 x: 129.0,
17603 y: 128.0,
17604 building_id: None,
17605 entity_id: None,
17606 life_state: None,
17607 hp_pct: None,
17608 can_trade: true,
17609 buy_templates: vec!["lumber".into()],
17610 tile_id: None,
17611 behavior_state: None,
17612 presentation_state: None,
17613 sprite_mode: None,
17614 paperdoll_ref: None,
17615 draw_scale: 1.0,
17616 yaw: None,
17617 perception_fov_deg: None,
17618 perception_sight_m: None,
17619 perception_hear_m: None,
17620 quest_verbs: Vec::new(),
17621 });
17622 state.ground_drops.push(flatland_protocol::GroundDropView {
17623 id: "d1".into(),
17624 template_id: "lumber".into(),
17625 quantity: 1,
17626 x: 128.5,
17627 y: 128.0,
17628 z: 0.0,
17629 tile_id: None,
17630 display_name: None,
17631 yaw: 0.0,
17632 pitch: 0.0,
17633 roll: 0.0,
17634 draw_scale: 1.0,
17635 item_instance_id: None,
17636 props: Default::default(),
17637 status_bindings: Vec::new(),
17638 });
17639 let probe = state.probe_use_world();
17640 let primary = probe.primary.expect("primary");
17641 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
17642 assert_eq!(primary.id, "ada");
17643 }
17644
17645 #[test]
17646 fn probe_use_world_harvest_when_in_range() {
17647 let state = sample_state(); let probe = state.probe_use_world();
17649 assert!(
17650 probe.primary.is_none(),
17651 "oak is 2m away, out of harvest range"
17652 );
17653 assert!(probe
17654 .candidates
17655 .iter()
17656 .any(|c| c.kind == crate::UseWorldKind::Harvest));
17657
17658 let mut state = sample_state();
17659 state.resource_nodes[0].x = 129.0;
17660 let probe = state.probe_use_world();
17661 let primary = probe.primary.expect("primary");
17662 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17663 }
17664
17665 #[test]
17666 fn probe_use_world_door_uses_building_label() {
17667 let mut state = sample_state();
17668 state.doors[0].x = 129.0;
17669 state.doors[0].y = 128.0;
17670 let probe = state.probe_use_world();
17671 let primary = probe.primary.expect("primary");
17672 assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
17673 assert_eq!(primary.label, "Broker");
17674 assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
17675 }
17676
17677 #[test]
17678 fn empty_entity_tick_preserves_welcome_snapshot() {
17679 let mut state = sample_state();
17680 state.inventory.insert("carrot".into(), 3);
17681 let delta = TickDelta {
17682 tick: 1,
17683 entities: vec![],
17684 resource_nodes: vec![],
17685 ground_drops: vec![],
17686 placed_containers: vec![],
17687 buildings: vec![],
17688 doors: vec![],
17689 interior_map: None,
17690 npcs: vec![],
17691 inventory: vec![],
17692 blueprints: vec![],
17693 building_materials: vec![],
17694 world_clock: flatland_protocol::WorldClock::default(),
17695 combat: None,
17696 quest_log: vec![],
17697 hired_workers: Vec::new(),
17698 interactables: vec![],
17699 ledger: None,
17700 career: None,
17701 combat_fx: Vec::new(),
17702 ground_hazards: Vec::new(),
17703 property_plots: Vec::new(),
17704 terrain_overlays: Vec::new(),
17705 };
17706
17707 state.apply_tick_fields(&delta, 1);
17708
17709 assert_eq!(state.entities.len(), 1);
17710 assert!(state.player.is_some());
17711 assert_eq!(state.inventory.get("carrot"), Some(&3));
17712 assert_eq!(state.resource_nodes.len(), 1);
17713 }
17714
17715 #[test]
17716 fn tick_preserves_world_layers_when_delta_omits_them() {
17717 let mut state = sample_state();
17718 let delta = TickDelta {
17719 tick: 1,
17720 entities: state.entities.clone(),
17721 resource_nodes: vec![],
17722 ground_drops: vec![],
17723 placed_containers: vec![],
17724 buildings: vec![],
17725 doors: vec![],
17726 interior_map: None,
17727 npcs: vec![],
17728 inventory: vec![],
17729 blueprints: vec![],
17730 building_materials: vec![],
17731 world_clock: flatland_protocol::WorldClock::default(),
17732 combat: None,
17733 quest_log: vec![],
17734 hired_workers: Vec::new(),
17735 interactables: vec![],
17736 ledger: None,
17737 career: None,
17738 combat_fx: Vec::new(),
17739 ground_hazards: Vec::new(),
17740 property_plots: Vec::new(),
17741 terrain_overlays: Vec::new(),
17742 };
17743
17744 state.apply_tick_fields(&delta, 1);
17745
17746 assert_eq!(state.resource_nodes.len(), 1);
17747 assert_eq!(state.buildings.len(), 1);
17748 assert_eq!(state.doors.len(), 1);
17749 }
17750
17751 #[test]
17752 fn tick_updates_resource_nodes_when_server_sends_them() {
17753 let mut state = sample_state();
17754 let delta = TickDelta {
17755 tick: 1,
17756 entities: state.entities.clone(),
17757 resource_nodes: vec![ResourceNodeView {
17758 id: "oak-1".into(),
17759 label: "Oak".into(),
17760 x: 130.0,
17761 y: 128.0,
17762 z: 0.0,
17763 item_template: "oak_log".into(),
17764 state: ResourceNodeState::Cooldown,
17765 blocking: true,
17766 blocking_radius_m: 0.8,
17767 harvest_off: false,
17768 tile_id: None,
17769 yaw: 0.0,
17770 pitch: 0.0,
17771 roll: 0.0,
17772 draw_scale: 1.0,
17773 sprite_mode: None,
17774 growth_progress: None,
17775 presentation_state: None,
17776 channel_start_tick: None,
17777 channel_end_tick: None,
17778 harvest_drop_templates: vec![],
17779 }],
17780 buildings: vec![],
17781 doors: vec![],
17782 interior_map: None,
17783 npcs: vec![],
17784 inventory: vec![],
17785 blueprints: vec![],
17786 building_materials: vec![],
17787 world_clock: flatland_protocol::WorldClock::default(),
17788 ground_drops: vec![],
17789 placed_containers: vec![],
17790 combat: None,
17791 quest_log: vec![],
17792 hired_workers: Vec::new(),
17793 interactables: vec![],
17794 ledger: None,
17795 career: None,
17796 combat_fx: Vec::new(),
17797 ground_hazards: Vec::new(),
17798 property_plots: Vec::new(),
17799 terrain_overlays: Vec::new(),
17800 };
17801
17802 state.apply_tick_fields(&delta, 1);
17803
17804 assert!(matches!(
17805 state.resource_nodes[0].state,
17806 ResourceNodeState::Cooldown
17807 ));
17808 }
17809
17810 #[test]
17811 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
17812 let mut state = GameState {
17813 session_id: 1,
17814 entity_id: 1,
17815 character_id: None,
17816 tick: 0,
17817 chunk_rev: 0,
17818 content_rev: 0,
17819 publish_rev: 0,
17820 entities: vec![EntityState {
17821 id: 1,
17822 label: "You".into(),
17823 transform: Transform {
17824 position: WorldCoord::surface(4.5, 2.0),
17825 yaw: 0.0,
17826 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
17827 },
17828 vitals: None,
17829 attributes: None,
17830 skills: None,
17831 inside_building: Some("broker_hut".into()),
17832 tile_id: None,
17833 paperdoll_ref: None,
17834 draw_scale: 1.0,
17835 presentation_state: None,
17836 sprite_mode: None,
17837 progression_xp: None,
17838 combat_cues: vec![],
17839 statuses: vec![],
17840 }],
17841 player: None,
17842 resource_nodes: vec![],
17843 ground_drops: vec![],
17844 placed_containers: vec![],
17845 buildings: vec![BuildingView {
17846 id: "broker_hut".into(),
17847 label: "Broker".into(),
17848 x: 158.0,
17849 y: 124.0,
17850 width_m: 8.0,
17851 depth_m: 6.0,
17852 interior_blueprint: Some("broker_hut".into()),
17853 tags: vec![],
17854 market_boundary_zone_ids: vec![],
17855 market_max_volume: None,
17856 wall_set: None,
17857 roof_set: None,
17858 }],
17859 doors: vec![flatland_protocol::DoorView {
17860 id: "broker_hut_exit".into(),
17861 building_id: "broker_hut".into(),
17862 x: 4.3,
17863 y: 0.9,
17864 open: true,
17865 portal: Some("front".into()),
17866 locked: false,
17867 accessible: true,
17868 lock_id: None,
17869 }],
17870 interior_map: None,
17871 npcs: vec![flatland_protocol::NpcView {
17872 id: "ada_broker".into(),
17873 label: "Ada".into(),
17874 x: 4.5,
17875 y: 2.0,
17876 building_id: Some("broker_hut".into()),
17877 role: "broker".into(),
17878 entity_id: None,
17879 life_state: None,
17880 hp_pct: None,
17881 can_trade: true,
17882 buy_templates: vec!["lumber".into()],
17883 tile_id: None,
17884 behavior_state: None,
17885 presentation_state: None,
17886 sprite_mode: None,
17887 paperdoll_ref: None,
17888 draw_scale: 1.0,
17889 yaw: None,
17890 perception_fov_deg: None,
17891 perception_sight_m: None,
17892 perception_hear_m: None,
17893 quest_verbs: Vec::new(),
17894 }],
17895 blueprints: vec![],
17896 building_materials: vec![],
17897 world_x0: 0.0,
17898 world_y0: 0.0,
17899 world_width_m: 256.0,
17900 world_height_m: 256.0,
17901 terrain_zones: Vec::new(),
17902 z_platforms: Vec::new(),
17903 z_transitions: Vec::new(),
17904 z_bands_outdoor_backup: None,
17905 world_clock: flatland_protocol::WorldClock::default(),
17906 inventory: std::collections::HashMap::new(),
17907 inventory_hints: std::collections::HashMap::new(),
17908 item_catalog: std::collections::HashMap::new(),
17909 logs: VecDeque::new(),
17910 intents_sent: 0,
17911 ticks_received: 0,
17912 connected: true,
17913 disconnect_reason: None,
17914 show_stats: false,
17915 hud_log_hidden: false,
17916 show_equip_menu: false,
17917 equip_menu_index: 0,
17918 show_craft_menu: false,
17919 show_plot_build_menu: false,
17920 plot_build_focus_wall: true,
17921 plot_build_wall_index: 0,
17922 plot_build_roof_index: 0,
17923 craft_menu_index: 0,
17924 craft_batch_quantity: 1,
17925 craft_tab: CraftTab::Ready,
17926 craft_filter: String::new(),
17927 craft_filter_focused: false,
17928 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
17929 show_shop_menu: false,
17930 shop_catalog: None,
17931 bank_panel: None,
17932 bank_menu_index: 0,
17933 bank_ui_mode: BankUiMode::Menu,
17934 storage_panel: None,
17935 market_panel: None,
17936 market_menu_index: 0,
17937 market_filter: String::new(),
17938 market_filter_focused: false,
17939 market_category_filter: None,
17940 market_buy_confirm: None,
17941 market_ui_mode: MarketUiMode::Browse,
17942 storage_menu_index: 0,
17943 storage_ui_mode: StorageUiMode::Menu,
17944 shop_tab: ShopTab::default(),
17945 shop_menu_index: 0,
17946 shop_quantity: 1,
17947 shop_trade_log: VecDeque::new(),
17948 show_npc_verb_menu: false,
17949 npc_verb_target: None,
17950 npc_verb_index: 0,
17951 npc_verb_notice: None,
17952 player_verbs: crate::social::PlayerVerbState::default(),
17953 social_chat: crate::social::SocialChatState::default(),
17954 trade_ui: crate::social::TradeUiState::default(),
17955 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
17956 show_npc_chat: false,
17957 npc_chat: None,
17958 show_inventory_menu: false,
17959 inventory_menu_index: 0,
17960 inventory_tab: InventoryTab::OnPerson,
17961 inventory_filter: String::new(),
17962 inventory_filter_focused: false,
17963 show_move_picker: false,
17964 show_rename_prompt: false,
17965 rename_plot_id: None,
17966 highlighted_plot_id: None,
17967 show_worker_rename: false,
17968 rename_buffer: String::new(),
17969 move_picker_index: 0,
17970 move_picker: None,
17971 show_grant_picker: false,
17972 grant_picker_index: 0,
17973 grant_picker: None,
17974 show_destroy_picker: false,
17975 destroy_confirm_pending: false,
17976 destroy_picker: None,
17977 combat_target: None,
17978 combat_target_label: None,
17979 ground_target: None,
17980 combat_fx: Vec::new(),
17981 ground_hazards: Vec::new(),
17982 property_zones: Vec::new(),
17983 tax_zones: Vec::new(),
17984 growth_zones: Vec::new(),
17985 biome_zones: Vec::new(),
17986 terrain_kind_nav: Vec::new(),
17987 property_plots: Vec::new(),
17988 property_plot_settings: None,
17989 claim_mode: None,
17990 relocate_mode: None,
17991 sell_plot_confirm: None,
17992 sell_plot_armed_at: None,
17993 show_plant_menu: false,
17994 plant_menu_index: 0,
17995 show_farm_access: false,
17996 farm_access_name_draft: String::new(),
17997 farm_access_discount_bps: 0,
17998 farm_access_index: 0,
17999 plant_quantity: 1,
18000 in_combat: false,
18001 auto_attack: true,
18002 combat_has_los: false,
18003 attack_cd_ticks: 0,
18004 gcd_ticks: 0,
18005 weapon_ability_id: "unarmed".into(),
18006 mainhand_template_id: None,
18007 mainhand_label: None,
18008 mainhand_instance_id: None,
18009 offhand_template_id: None,
18010 offhand_label: None,
18011 offhand_instance_id: None,
18012 mainhand_hand_slots: 1,
18013 defense: None,
18014 worn: BTreeMap::new(),
18015 carry_mass: 0.0,
18016 carry_mass_max: 0.0,
18017 encumbrance: flatland_protocol::EncumbranceState::Light,
18018 move_speed_mps: 0.0,
18019 move_speed_mult: 0.0,
18020 inventory_stacks: Vec::new(),
18021 keychain_stacks: Vec::new(),
18022 whisper_pouch_stacks: Vec::new(),
18023 combat_target_detail: None,
18024 statuses: Vec::new(),
18025 cast_progress: None,
18026 timed_channel: None,
18027 plot_build_offer: None,
18028 ability_cooldowns: Vec::new(),
18029 blocking_active: false,
18030 max_target_slots: 1,
18031 combat_slots: Vec::new(),
18032 rotation_presets: Vec::new(),
18033 known_abilities: Vec::new(),
18034 ability_meta: std::collections::HashMap::new(),
18035 ability_mastery: std::collections::HashMap::new(),
18036 hotbar: vec![None; 9],
18037 max_abilities_per_rotation: 0,
18038 show_loadout_menu: false,
18039 show_keychain_menu: false,
18040 keychain_menu_index: 0,
18041 show_rotation_editor: false,
18042 loadout_menu_index: 0,
18043 loadout_hotbar_slot: 1,
18044 loadout_ability_index: 0,
18045 loadout_focus_presets: false,
18046 rotation_editor: RotationEditorState::default(),
18047 harvest_in_progress: false,
18048 harvest_started_at: None,
18049 pending_craft_ack: None,
18050 craft_channel_blueprint_id: None,
18051 pending_worker_job_ack: None,
18052 attending_worker_instance_id: None,
18053 quest_log: Vec::new(),
18054 interactables: Vec::new(),
18055 ledger: None,
18056 career: None,
18057 character_sheet_tab: CharacterSheetTab::Character,
18058 ledger_period: LedgerPeriod::Day,
18059 show_quest_offer: false,
18060 pending_quest_offers: Vec::new(),
18061 quest_offer_index: 0,
18062 show_quest_menu: false,
18063 quest_menu_index: 0,
18064 quest_withdraw_confirm: false,
18065 hired_workers: Vec::new(),
18066 show_workers_menu: false,
18067 workers_menu_index: 0,
18068 worker_dismiss_confirmation: None,
18069 workers_menu_compact: false,
18070 worker_step_display: BTreeMap::new(),
18071 worker_error_display: BTreeMap::new(),
18072 worker_health_ring_until: BTreeMap::new(),
18073 pending_worker_hire_since: None,
18074 show_worker_give_picker: false,
18075 worker_give_picker_index: 0,
18076 worker_give_picker: None,
18077 show_worker_give_target_picker: false,
18078 worker_give_target_picker_index: 0,
18079 worker_give_target_picker: None,
18080 show_worker_take_picker: false,
18081 worker_take_picker_index: 0,
18082 worker_take_picker: None,
18083 show_worker_teach_picker: false,
18084 worker_teach_picker_index: 0,
18085 worker_teach_picker: None,
18086 worker_route_editor: None,
18087 progression_curve: None,
18088 };
18089 state.player = state.entities.first().cloned();
18090 assert_eq!(
18091 state.nearest_interact_target().as_deref(),
18092 Some("ada_broker")
18093 );
18094 }
18095
18096 #[test]
18097 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
18098 let mut state = sample_state();
18099 state.placed_containers = vec![
18102 flatland_protocol::PlacedContainerView {
18103 id: "near".into(),
18104 template_id: "wooden_chest_small".into(),
18105 display_name: "Wooden Chest".into(),
18106 x: 130.0,
18107 y: 128.0,
18108 z: 0.0,
18109 locked: true,
18110 accessible: true,
18111 owner_character_id: None,
18112 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
18113 lock_id: None,
18114 capacity_volume: None,
18115 item_instance_id: Some(uuid::Uuid::from_u128(1)),
18116 tile_id: None,
18117 worker_lodging_capacity: None,
18118 blocking: false,
18119 blocking_radius_m: 0.0,
18120 building_id: None,
18121 },
18122 flatland_protocol::PlacedContainerView {
18123 id: "far".into(),
18124 template_id: "wooden_chest_small".into(),
18125 display_name: "Distant Chest".into(),
18126 x: 128.0 + CONTAINER_RANGE_M + 5.0,
18127 y: 128.0,
18128 z: 0.0,
18129 locked: false,
18130 accessible: true,
18131 owner_character_id: None,
18132 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
18133 lock_id: None,
18134 capacity_volume: None,
18135 item_instance_id: Some(uuid::Uuid::from_u128(2)),
18136 tile_id: None,
18137 worker_lodging_capacity: None,
18138 blocking: false,
18139 blocking_radius_m: 0.0,
18140 building_id: None,
18141 },
18142 ];
18143
18144 let nearby = state.nearby_containers();
18145 assert_eq!(
18146 nearby.len(),
18147 1,
18148 "far chest must not appear once out of range"
18149 );
18150 assert_eq!(nearby[0].view.id, "near");
18151 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
18152 assert!(nearby[0].rows[0].is_chest_shell);
18153
18154 state.placed_containers[0].accessible = false;
18157 let nearby = state.nearby_containers();
18158 assert_eq!(nearby.len(), 1);
18159 assert_eq!(nearby[0].rows.len(), 1);
18160 assert!(nearby[0].rows[0].is_chest_shell);
18161 }
18162
18163 #[test]
18164 fn chest_pickup_destinations_offer_person_and_worn_bag() {
18165 let mut state = sample_state();
18166 let back_id = uuid::Uuid::from_u128(42);
18167 state.worn.insert(
18168 BodySlot::Back,
18169 flatland_protocol::ItemStack {
18170 template_id: "travel_backpack".into(),
18171 quantity: 1,
18172 item_instance_id: Some(back_id),
18173 props: Default::default(),
18174 status_bindings: Vec::new(),
18175 contents: Vec::new(),
18176 display_name: Some("Travel Backpack".into()),
18177 category: Some("container".into()),
18178 base_mass: Some(2.5),
18179 base_volume: Some(12.0),
18180 capacity_volume: Some(80.0),
18181 stackable: Some(false),
18182 world_placeable: Some(false),
18183 worker_lodging_capacity: None,
18184 equip_slot: None,
18185 armor_physical: None,
18186 resists: vec![],
18187 hand_slots: None,
18188 listable: None,
18189 ..Default::default()
18190 },
18191 );
18192 let opts = state.chest_pickup_destinations("chest-1");
18193 assert!(matches!(
18194 opts.first().map(|o| &o.kind),
18195 Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
18196 ));
18197 assert!(opts.iter().any(|o| matches!(
18198 &o.kind,
18199 MoveOptionKind::PickupPlaced {
18200 nest_parent_instance_id: None,
18201 ..
18202 }
18203 )));
18204 assert!(opts.iter().any(|o| matches!(
18205 &o.kind,
18206 MoveOptionKind::PickupPlaced {
18207 nest_parent_instance_id: Some(id),
18208 ..
18209 } if *id == back_id
18210 )));
18211 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18212 }
18213
18214 #[test]
18215 fn placed_container_public_label_hides_owner_custom_name() {
18216 let owner = uuid::Uuid::from_u128(99);
18217 let mut state = sample_state();
18218 state.character_id = Some(uuid::Uuid::from_u128(1));
18219 state.inventory_hints.insert(
18220 "wooden_chest_medium".into(),
18221 InventoryHint {
18222 display_name: "Medium Wooden Chest".into(),
18223 category: "container".into(),
18224 base_mass: None,
18225 base_volume: None,
18226 capacity_volume: None,
18227 stackable: false,
18228 listable: true,
18229 base_value_copper: None,
18230 },
18231 );
18232 let chest = flatland_protocol::PlacedContainerView {
18233 id: "c1".into(),
18234 template_id: "wooden_chest_medium".into(),
18235 display_name: "Barry's Loot #a3f2".into(),
18236 x: 128.0,
18237 y: 128.0,
18238 z: 0.0,
18239 locked: false,
18240 accessible: true,
18241 owner_character_id: Some(owner),
18242 contents: vec![],
18243 lock_id: None,
18244 capacity_volume: None,
18245 item_instance_id: None,
18246 tile_id: None,
18247 worker_lodging_capacity: None,
18248 blocking: false,
18249 blocking_radius_m: 0.0,
18250 building_id: None,
18251 };
18252 assert_eq!(
18253 state.placed_container_public_label(&chest),
18254 "Medium Wooden Chest"
18255 );
18256 state.character_id = Some(owner);
18257 assert_eq!(
18258 state.placed_container_public_label(&chest),
18259 "Barry's Loot #a3f2"
18260 );
18261 }
18262
18263 #[test]
18264 fn location_context_shows_crop_growth_percent_not_depleted() {
18265 let mut state = sample_state();
18266 state.player = state.entities.first().cloned();
18267 state.resource_nodes[0].label = "Carrot (growing)".into();
18268 state.resource_nodes[0].x = 128.2;
18269 state.resource_nodes[0].y = 128.0;
18270 state.resource_nodes[0].state = ResourceNodeState::Cooldown;
18271 state.resource_nodes[0].growth_progress = Some(0.47);
18272 let lines = state.location_context_lines();
18273 let line = lines
18274 .iter()
18275 .find(|l| l.text.contains("Carrot"))
18276 .map(|l| l.text.as_str())
18277 .unwrap_or("");
18278 assert!(
18279 line.contains("(growing, 47%)"),
18280 "expected growth percent, got: {line}"
18281 );
18282 assert!(
18283 !line.contains("depleted"),
18284 "growing crop should not show depleted: {line}"
18285 );
18286 }
18287
18288 #[test]
18289 fn resource_node_near_action_suffix_prefers_growth() {
18290 let node = ResourceNodeView {
18291 id: "crop".into(),
18292 label: "Wheat".into(),
18293 x: 0.0,
18294 y: 0.0,
18295 z: 0.0,
18296 item_template: "wheat".into(),
18297 state: ResourceNodeState::Cooldown,
18298 blocking: false,
18299 blocking_radius_m: 0.0,
18300 harvest_off: false,
18301 tile_id: None,
18302 yaw: 0.0,
18303 pitch: 0.0,
18304 roll: 0.0,
18305 draw_scale: 1.0,
18306 sprite_mode: None,
18307 growth_progress: Some(0.12),
18308 presentation_state: None,
18309 channel_start_tick: None,
18310 channel_end_tick: None,
18311 harvest_drop_templates: vec![],
18312 };
18313 assert_eq!(resource_node_near_action_suffix(&node), " (growing, 12%)");
18314 }
18315
18316 #[test]
18317 fn location_context_lists_nearby_resource_node() {
18318 let mut state = sample_state();
18319 state.player = state.entities.first().cloned();
18320 state.resource_nodes[0].x = 128.2;
18321 state.resource_nodes[0].y = 128.0;
18322 let lines = state.location_context_lines();
18323 assert!(
18324 lines
18325 .iter()
18326 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
18327 "expected resource node in context: {:?}",
18328 lines
18329 );
18330 }
18331
18332 #[test]
18333 fn quest_board_usable_within_board_radius() {
18334 let mut state = sample_state();
18335 state.player = state.entities.first().cloned();
18336 state.interactables = vec![flatland_protocol::InteractableView {
18337 id: "board-1".into(),
18338 kind: "quest_board".into(),
18339 label: "Town Quest Board".into(),
18340 x: 130.5,
18341 y: 128.0,
18342 z: 0.0,
18343 board_id: Some("starter_town_board".into()),
18344 }];
18345 assert_eq!(
18347 state.nearest_interact_target().as_deref(),
18348 Some("board-1"),
18349 "quest board should be selectable at ~2.5m"
18350 );
18351 let lines = state.location_context_lines();
18352 assert!(
18353 lines
18354 .iter()
18355 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
18356 "HUD should advertise f when board is in range: {:?}",
18357 lines
18358 );
18359 }
18360
18361 #[test]
18362 fn quest_board_keeps_multiple_offers() {
18363 let mut state = sample_state();
18364 let offer = |id: &str, title: &str| flatland_protocol::QuestOffer {
18365 quest_id: id.into(),
18366 title: title.into(),
18367 description: format!("{title} desc"),
18368 step_count: 2,
18369 };
18370 state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18371 state.push_quest_offer(offer("daily_20695_1", "Town board: Deer threat"));
18372 state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18373 assert_eq!(state.pending_quest_offers.len(), 2);
18374 assert_eq!(
18375 state.selected_quest_offer().unwrap().quest_id,
18376 "ada_goblin_hunt"
18377 );
18378 state.move_quest_offer_selection(1);
18379 assert_eq!(
18380 state.selected_quest_offer().unwrap().quest_id,
18381 "daily_20695_1"
18382 );
18383 state.remove_quest_offer("daily_20695_1");
18384 assert_eq!(state.pending_quest_offers.len(), 1);
18385 assert!(state.show_quest_offer);
18386 state.remove_quest_offer("ada_goblin_hunt");
18387 assert!(!state.show_quest_offer);
18388 assert!(state.pending_quest_offers.is_empty());
18389 }
18390
18391 #[test]
18392 fn inventory_selectable_rows_excludes_equipped_shells_but_keeps_bag_contents() {
18393 let mut state = sample_state();
18394 state.worn.insert(
18395 BodySlot::Back,
18396 flatland_protocol::ItemStack {
18397 template_id: "travel_backpack".into(),
18398 quantity: 1,
18399 item_instance_id: Some(uuid::Uuid::from_u128(3)),
18400 props: Default::default(),
18401 status_bindings: Vec::new(),
18402 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
18403 display_name: None,
18404 category: None,
18405 base_mass: None,
18406 base_volume: None,
18407 capacity_volume: None,
18408 stackable: None,
18409 world_placeable: None,
18410 worker_lodging_capacity: None,
18411 equip_slot: None,
18412 armor_physical: None,
18413 resists: vec![],
18414 hand_slots: None,
18415 listable: None,
18416 ..Default::default()
18417 },
18418 );
18419 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
18420 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18421 id: "chest-1".into(),
18422 template_id: "wooden_chest_small".into(),
18423 display_name: "Wooden Chest".into(),
18424 x: 129.0,
18425 y: 128.0,
18426 z: 0.0,
18427 locked: false,
18428 accessible: true,
18429 owner_character_id: None,
18430 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
18431 lock_id: None,
18432 capacity_volume: None,
18433 item_instance_id: Some(uuid::Uuid::from_u128(4)),
18434 tile_id: None,
18435 worker_lodging_capacity: None,
18436 blocking: false,
18437 blocking_radius_m: 0.0,
18438 building_id: None,
18439 }];
18440
18441 state.inventory_tab = InventoryTab::OnPerson;
18442 let rows = state.inventory_selectable_rows();
18443 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
18444 assert_eq!(
18445 sections,
18446 vec![
18447 InventorySection::Person, InventorySection::Person, ]
18450 );
18451 assert_eq!(rows[0].stack.template_id, "iron_ore");
18452 assert_eq!(rows[0].depth, 0);
18453 assert!(!rows[0].is_equip_shell);
18454 assert_eq!(rows[1].stack.template_id, "lumber");
18455
18456 let lines = state.inventory_browser_lines();
18457 assert!(lines.iter().any(|l| matches!(
18458 l,
18459 InventoryBrowserLine::Section(s) if s.contains("carried bags")
18460 )));
18461 assert!(lines.iter().any(|l| matches!(
18462 l,
18463 InventoryBrowserLine::Item { text, .. } if text.contains("iron_ore")
18464 )));
18465 assert!(!lines.iter().any(|l| matches!(
18466 l,
18467 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
18468 )));
18469 assert!(!lines.iter().any(|l| matches!(
18470 l,
18471 InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
18472 )));
18473
18474 state.inventory_tab = InventoryTab::Nearby;
18475 let nearby_rows = state.inventory_selectable_rows();
18476 assert_eq!(nearby_rows.len(), 2);
18477 assert!(nearby_rows[0].is_chest_shell);
18478 assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
18479 let nearby_lines = state.inventory_browser_lines();
18480 assert!(nearby_lines.iter().any(|l| matches!(
18481 l,
18482 InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
18483 )));
18484 }
18485
18486 #[test]
18487 fn give_worker_notice_does_not_put_item_back_in_bag() {
18488 let mut state = sample_state();
18489 let id = uuid::Uuid::from_u128(42);
18490 let mut saw = flatland_protocol::ItemStack::simple("handsaw", 1);
18491 saw.item_instance_id = Some(id);
18492 saw.display_name = Some("Handsaw".into());
18493 state.sync_inventory_from_stacks(&[saw]);
18494 assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 1);
18495
18496 state.remove_carried_instance(id, None);
18497 assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 0);
18498 assert!(state.inventory_stacks.is_empty());
18499
18500 state.apply_interaction_notice(&flatland_protocol::InteractionNotice {
18501 target_id: "worker-1".into(),
18502 message: "Gave 1x Handsaw to Laborer".into(),
18503 coins_delta: 0,
18504 inventory_delta: vec![flatland_protocol::ItemStack::simple("handsaw", 1)],
18505 });
18506 assert_eq!(
18507 state.inventory.get("handsaw").copied().unwrap_or(0),
18508 0,
18509 "Gave notice must not restore the handed stack"
18510 );
18511 }
18512
18513 #[test]
18514 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
18515 let mut state = sample_state();
18516 let back_id = uuid::Uuid::from_u128(5);
18517 state.worn.insert(
18518 BodySlot::Back,
18519 flatland_protocol::ItemStack {
18520 template_id: "travel_backpack".into(),
18521 quantity: 1,
18522 item_instance_id: Some(back_id),
18523 props: Default::default(),
18524 status_bindings: Vec::new(),
18525 contents: Vec::new(),
18526 display_name: None,
18527 category: Some("container".into()),
18528 base_mass: None,
18529 base_volume: None,
18530 capacity_volume: Some(80.0),
18531 stackable: None,
18532 world_placeable: None,
18533 worker_lodging_capacity: None,
18534 equip_slot: None,
18535 armor_physical: None,
18536 resists: vec![],
18537 hand_slots: None,
18538 listable: None,
18539 ..Default::default()
18540 },
18541 );
18542 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18543 id: "chest-1".into(),
18544 template_id: "wooden_chest_small".into(),
18545 display_name: "Wooden Chest".into(),
18546 x: 129.0,
18547 y: 128.0,
18548 z: 0.0,
18549 locked: false,
18550 accessible: true,
18551 owner_character_id: None,
18552 contents: Vec::new(),
18553 lock_id: None,
18554 capacity_volume: None,
18555 item_instance_id: Some(uuid::Uuid::from_u128(6)),
18556 tile_id: None,
18557 worker_lodging_capacity: None,
18558 blocking: false,
18559 blocking_radius_m: 0.0,
18560 building_id: None,
18561 }];
18562
18563 let opts = state.move_destinations_for(
18566 &flatland_protocol::InventoryLocation::Root,
18567 None,
18568 None,
18569 "lumber",
18570 );
18571 assert!(!opts.iter().any(|o| matches!(
18572 &o.kind,
18573 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18574 )));
18575 assert!(opts.iter().any(|o| matches!(
18576 &o.kind,
18577 MoveOptionKind::Move { location, parent_instance_id, .. }
18578 if *location == flatland_protocol::InventoryLocation::Worn {
18579 slot: BodySlot::Back,
18580 } && *parent_instance_id == Some(back_id)
18581 )));
18582 assert!(opts.iter().any(|o| matches!(
18583 &o.kind,
18584 MoveOptionKind::Move { location, .. }
18585 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
18586 )));
18587 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18588 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
18589
18590 let from_backpack = flatland_protocol::InventoryLocation::Worn {
18594 slot: BodySlot::Back,
18595 };
18596 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
18597 assert!(!opts.iter().any(|o| matches!(
18598 &o.kind,
18599 MoveOptionKind::Move { location, parent_instance_id, .. }
18600 if *location == from_backpack && *parent_instance_id == Some(back_id)
18601 )));
18602 assert!(opts.iter().any(|o| matches!(
18603 &o.kind,
18604 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18605 )));
18606 }
18607
18608 #[test]
18609 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
18610 let mut state = sample_state();
18611 state.worn.insert(
18614 BodySlot::Waist,
18615 flatland_protocol::ItemStack {
18616 template_id: "simple_belt".into(),
18617 quantity: 1,
18618 item_instance_id: Some(uuid::Uuid::from_u128(10)),
18619 props: Default::default(),
18620 status_bindings: Vec::new(),
18621 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
18622 display_name: None,
18623 category: Some("container".into()),
18624 base_mass: None,
18625 base_volume: None,
18626 capacity_volume: None,
18627 stackable: None,
18628 world_placeable: None,
18629 worker_lodging_capacity: None,
18630 equip_slot: None,
18631 armor_physical: None,
18632 resists: vec![],
18633 hand_slots: None,
18634 listable: None,
18635 ..Default::default()
18636 },
18637 );
18638 state.worn.insert(
18639 BodySlot::Head,
18640 flatland_protocol::ItemStack {
18641 template_id: "cloth_cap".into(),
18642 quantity: 1,
18643 item_instance_id: Some(uuid::Uuid::from_u128(11)),
18644 props: Default::default(),
18645 status_bindings: Vec::new(),
18646 contents: Vec::new(),
18647 display_name: None,
18648 category: Some("armor".into()),
18649 base_mass: None,
18650 base_volume: None,
18651 capacity_volume: None,
18652 stackable: None,
18653 world_placeable: None,
18654 worker_lodging_capacity: None,
18655 equip_slot: None,
18656 armor_physical: None,
18657 resists: vec![],
18658 hand_slots: None,
18659 listable: None,
18660 ..Default::default()
18661 },
18662 );
18663 state.worn.insert(
18664 BodySlot::Back,
18665 flatland_protocol::ItemStack {
18666 template_id: "travel_backpack".into(),
18667 quantity: 1,
18668 item_instance_id: Some(uuid::Uuid::from_u128(12)),
18669 props: Default::default(),
18670 status_bindings: Vec::new(),
18671 contents: Vec::new(),
18672 display_name: None,
18673 category: Some("container".into()),
18674 base_mass: None,
18675 base_volume: None,
18676 capacity_volume: None,
18677 stackable: None,
18678 world_placeable: None,
18679 worker_lodging_capacity: None,
18680 equip_slot: None,
18681 armor_physical: None,
18682 resists: vec![],
18683 hand_slots: None,
18684 listable: None,
18685 ..Default::default()
18686 },
18687 );
18688
18689 let rows = state.worn_rows();
18690 assert_eq!(rows.len(), 4);
18692 assert_eq!(rows[0].stack.template_id, "cloth_cap");
18693 assert!(rows[0].is_equip_shell);
18694 assert_eq!(rows[1].stack.template_id, "travel_backpack");
18695 assert!(rows[1].is_equip_shell);
18696 assert_eq!(rows[2].stack.template_id, "simple_belt");
18697 assert!(rows[2].is_equip_shell);
18698 assert_eq!(rows[3].stack.template_id, "leather_pouch");
18699 assert_eq!(rows[3].depth, 1);
18700 assert!(!rows[3].is_equip_shell);
18701 }
18702
18703 #[test]
18704 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
18705 let mut state = sample_state();
18706 state.worn.insert(
18707 BodySlot::Waist,
18708 flatland_protocol::ItemStack {
18709 template_id: "simple_belt".into(),
18710 quantity: 1,
18711 item_instance_id: Some(uuid::Uuid::from_u128(20)),
18712 props: Default::default(),
18713 status_bindings: Vec::new(),
18714 contents: Vec::new(),
18715 display_name: Some("Simple Belt".into()),
18716 category: Some("container".into()),
18717 base_mass: None,
18718 base_volume: None,
18719 capacity_volume: None,
18720 stackable: None,
18721 world_placeable: None,
18722 worker_lodging_capacity: None,
18723 equip_slot: None,
18724 armor_physical: None,
18725 resists: vec![],
18726 hand_slots: None,
18727 listable: None,
18728 ..Default::default()
18729 },
18730 );
18731 state.worn.insert(
18732 BodySlot::Head,
18733 flatland_protocol::ItemStack {
18734 template_id: "cloth_cap".into(),
18735 quantity: 1,
18736 item_instance_id: Some(uuid::Uuid::from_u128(21)),
18737 props: Default::default(),
18738 status_bindings: Vec::new(),
18739 contents: Vec::new(),
18740 display_name: Some("Cloth Cap".into()),
18741 category: Some("armor".into()),
18742 base_mass: None,
18743 base_volume: None,
18744 capacity_volume: None,
18745 stackable: None,
18746 world_placeable: None,
18747 worker_lodging_capacity: None,
18748 equip_slot: None,
18749 armor_physical: None,
18750 resists: vec![],
18751 hand_slots: None,
18752 listable: None,
18753 ..Default::default()
18754 },
18755 );
18756
18757 let opts = state.move_destinations_for(
18758 &flatland_protocol::InventoryLocation::Root,
18759 None,
18760 None,
18761 "leather_pouch",
18762 );
18763 assert!(
18764 opts.iter().any(|o| matches!(
18765 &o.kind,
18766 MoveOptionKind::Move { location, .. }
18767 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18768 )),
18769 "belt loop must be offered when moving a pouch"
18770 );
18771 assert!(
18772 !opts.iter().any(|o| matches!(
18773 &o.kind,
18774 MoveOptionKind::Move { location, .. }
18775 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
18776 )),
18777 "armor slots can't hold other items and must not appear as move destinations"
18778 );
18779 let belt_opt = opts
18780 .iter()
18781 .find(|o| matches!(
18782 &o.kind,
18783 MoveOptionKind::Move { location, .. }
18784 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18785 ))
18786 .unwrap();
18787 assert!(belt_opt.label.contains("belt loop"));
18788
18789 let opts = state.move_destinations_for(
18790 &flatland_protocol::InventoryLocation::Root,
18791 None,
18792 None,
18793 "lumber",
18794 );
18795 assert!(
18796 !opts.iter().any(|o| o.label.contains("belt loop")),
18797 "loose materials must not target the belt shell — only nested pouches"
18798 );
18799 }
18800
18801 #[test]
18802 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
18803 let mut state = sample_state();
18804 let belt_id = uuid::Uuid::from_u128(30);
18805 let pouch_id = uuid::Uuid::from_u128(31);
18806 state.worn.insert(
18807 BodySlot::Waist,
18808 flatland_protocol::ItemStack {
18809 template_id: "simple_belt".into(),
18810 quantity: 1,
18811 item_instance_id: Some(belt_id),
18812 props: Default::default(),
18813 status_bindings: Vec::new(),
18814 world_placeable: None,
18815 worker_lodging_capacity: None,
18816 equip_slot: None,
18817 armor_physical: None,
18818 resists: vec![],
18819 hand_slots: None,
18820 contents: vec![flatland_protocol::ItemStack {
18821 template_id: "dimensional_pouch".into(),
18822 quantity: 1,
18823 item_instance_id: Some(pouch_id),
18824 props: Default::default(),
18825 status_bindings: Vec::new(),
18826 contents: Vec::new(),
18827 display_name: Some("Dimensional Pouch".into()),
18828 category: Some("container".into()),
18829 base_mass: None,
18830 base_volume: None,
18831 capacity_volume: Some(200.0),
18832 stackable: None,
18833 world_placeable: None,
18834 worker_lodging_capacity: None,
18835 equip_slot: None,
18836 armor_physical: None,
18837 resists: vec![],
18838 hand_slots: None,
18839 listable: None,
18840 ..Default::default()
18841 }],
18842 display_name: Some("Simple Belt".into()),
18843 category: Some("container".into()),
18844 base_mass: None,
18845 base_volume: None,
18846 capacity_volume: None,
18847 stackable: None,
18848 listable: None,
18849 ..Default::default()
18850 },
18851 );
18852
18853 let opts = state.move_destinations_for(
18854 &flatland_protocol::InventoryLocation::Root,
18855 None,
18856 None,
18857 "iron_ore",
18858 );
18859 assert!(
18860 opts.iter().any(|o| matches!(
18861 &o.kind,
18862 MoveOptionKind::Move {
18863 location,
18864 parent_instance_id,
18865 ..
18866 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18867 && *parent_instance_id == Some(pouch_id)
18868 )),
18869 "dimensional pouch clipped on belt must accept loose items"
18870 );
18871 assert!(
18872 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
18873 "destination label should name the pouch"
18874 );
18875 }
18876
18877 #[test]
18878 fn container_volume_label_on_placed_chest_shell() {
18879 let mut state = sample_state();
18880 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18881 id: "chest-1".into(),
18882 template_id: "wooden_chest_small".into(),
18883 display_name: "Camp Chest".into(),
18884 x: 129.0,
18885 y: 128.0,
18886 z: 0.0,
18887 locked: false,
18888 accessible: true,
18889 owner_character_id: None,
18890 contents: vec![flatland_protocol::ItemStack {
18891 template_id: "iron_ore".into(),
18892 quantity: 2,
18893 item_instance_id: None,
18894 props: Default::default(),
18895 status_bindings: Vec::new(),
18896 contents: Vec::new(),
18897 display_name: None,
18898 category: None,
18899 base_mass: None,
18900 base_volume: Some(2.0),
18901 capacity_volume: None,
18902 stackable: None,
18903 world_placeable: None,
18904 worker_lodging_capacity: None,
18905 equip_slot: None,
18906 armor_physical: None,
18907 resists: vec![],
18908 hand_slots: None,
18909 listable: None,
18910 ..Default::default()
18911 }],
18912 lock_id: None,
18913 capacity_volume: Some(60.0),
18914 item_instance_id: Some(uuid::Uuid::from_u128(4)),
18915 tile_id: None,
18916 worker_lodging_capacity: None,
18917 blocking: false,
18918 blocking_radius_m: 0.0,
18919 building_id: None,
18920 }];
18921 let nearby = state.nearby_containers();
18922 let label = state.container_volume_label(&nearby[0].rows[0]);
18923 assert!(
18924 label.contains("vol 4/60"),
18925 "expected used/cap in label, got {label}"
18926 );
18927 assert!(
18928 label.contains("56 free"),
18929 "expected free space, got {label}"
18930 );
18931 }
18932
18933 #[test]
18934 fn key_pair_chest_label_from_placed_lock_id() {
18935 let mut state = sample_state();
18936 let owner = uuid::Uuid::from_u128(77);
18937 state.character_id = Some(owner);
18938 let lock = uuid::Uuid::from_u128(99).to_string();
18939 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18940 id: "chest-1".into(),
18941 template_id: "wooden_chest_small".into(),
18942 display_name: "Barry's Loot #a3f2".into(),
18943 x: 129.0,
18944 y: 128.0,
18945 z: 0.0,
18946 locked: true,
18947 accessible: true,
18948 owner_character_id: Some(owner),
18949 contents: Vec::new(),
18950 lock_id: Some(lock.clone()),
18951 capacity_volume: None,
18952 item_instance_id: Some(uuid::Uuid::from_u128(4)),
18953 tile_id: None,
18954 worker_lodging_capacity: None,
18955 blocking: false,
18956 blocking_radius_m: 0.0,
18957 building_id: None,
18958 }];
18959 let key_id = uuid::Uuid::from_u128(5);
18960 let key = flatland_protocol::ItemStack {
18961 template_id: KEY_TEMPLATE.into(),
18962 quantity: 1,
18963 item_instance_id: Some(key_id),
18964 props: BTreeMap::from([
18965 (PROP_OPENS_LOCK_ID.into(), lock),
18966 (
18967 PROP_OPENS_CONTAINER_NAME.into(),
18968 "Barry's Loot #a3f2".into(),
18969 ),
18970 ]),
18971 status_bindings: Vec::new(),
18972 contents: Vec::new(),
18973 display_name: Some("Container Key".into()),
18974 category: Some("key".into()),
18975 base_mass: None,
18976 base_volume: None,
18977 capacity_volume: None,
18978 stackable: None,
18979 world_placeable: None,
18980 worker_lodging_capacity: None,
18981 equip_slot: None,
18982 armor_physical: None,
18983 resists: vec![],
18984 hand_slots: None,
18985 listable: None,
18986 ..Default::default()
18987 };
18988 state.inventory_stacks = vec![key.clone()];
18989 assert_eq!(
18990 state.key_pair_chest_label(&key).as_deref(),
18991 Some("Barry's Loot #a3f2")
18992 );
18993 assert!(state.key_drop_blocked(&key));
18994 }
18995
18996 #[test]
18997 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
18998 let mut state = sample_state();
18999 let lock = uuid::Uuid::from_u128(101).to_string();
19000 let key = flatland_protocol::ItemStack {
19001 template_id: KEY_TEMPLATE.into(),
19002 quantity: 1,
19003 item_instance_id: Some(uuid::Uuid::from_u128(7)),
19004 props: BTreeMap::from([
19005 (PROP_OPENS_LOCK_ID.into(), lock),
19006 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
19007 ]),
19008 status_bindings: Vec::new(),
19009 contents: Vec::new(),
19010 display_name: None,
19011 category: Some("key".into()),
19012 base_mass: None,
19013 base_volume: None,
19014 capacity_volume: None,
19015 stackable: None,
19016 world_placeable: None,
19017 worker_lodging_capacity: None,
19018 equip_slot: None,
19019 armor_physical: None,
19020 resists: vec![],
19021 hand_slots: None,
19022 listable: None,
19023 ..Default::default()
19024 };
19025 state.placed_containers.clear();
19026 assert_eq!(
19027 state.key_pair_chest_label(&key).as_deref(),
19028 Some("Camp Stash")
19029 );
19030 }
19031
19032 #[test]
19033 fn key_drop_allowed_when_paired_chest_unlocked() {
19034 let mut state = sample_state();
19035 let lock = uuid::Uuid::from_u128(100).to_string();
19036 let key_id = uuid::Uuid::from_u128(6);
19037 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19038 id: "chest-1".into(),
19039 template_id: "wooden_chest_small".into(),
19040 display_name: "Camp Chest".into(),
19041 x: 129.0,
19042 y: 128.0,
19043 z: 0.0,
19044 locked: false,
19045 accessible: true,
19046 owner_character_id: None,
19047 contents: Vec::new(),
19048 lock_id: Some(lock.clone()),
19049 capacity_volume: None,
19050 item_instance_id: None,
19051 tile_id: None,
19052 worker_lodging_capacity: None,
19053 blocking: false,
19054 blocking_radius_m: 0.0,
19055 building_id: None,
19056 }];
19057 let key = flatland_protocol::ItemStack {
19058 template_id: KEY_TEMPLATE.into(),
19059 quantity: 1,
19060 item_instance_id: Some(key_id),
19061 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
19062 status_bindings: Vec::new(),
19063 contents: Vec::new(),
19064 display_name: None,
19065 category: Some("key".into()),
19066 base_mass: None,
19067 base_volume: None,
19068 capacity_volume: None,
19069 stackable: None,
19070 world_placeable: None,
19071 worker_lodging_capacity: None,
19072 equip_slot: None,
19073 armor_physical: None,
19074 resists: vec![],
19075 hand_slots: None,
19076 listable: None,
19077 ..Default::default()
19078 };
19079 state.inventory_stacks = vec![key.clone()];
19080 assert!(!state.key_drop_blocked(&key));
19081 let opts = state.move_destinations_for(
19082 &flatland_protocol::InventoryLocation::Root,
19083 None,
19084 Some(key_id),
19085 KEY_TEMPLATE,
19086 );
19087 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
19088 }
19089
19090 #[test]
19091 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
19092 use flatland_protocol::{CombatHud, ProgressionCurve, ProgressionXp};
19093
19094 let mut state = sample_state();
19095 let curve = ProgressionCurve::default();
19096 let bootstrap =
19097 ProgressionXp::bootstrap_new(curve.baseline_display, curve.xp_base, curve.xp_growth);
19098 let mut fresh = bootstrap.clone();
19099 fresh.strength += 0.08;
19100 if let Some(player) = state.player.as_mut() {
19101 player.progression_xp = Some(bootstrap);
19102 }
19103
19104 let combat = CombatHud {
19105 progression_xp: Some(fresh.clone()),
19106 progression_baseline: curve.baseline_display,
19107 progression_xp_base: curve.xp_base,
19108 progression_xp_growth: curve.xp_growth,
19109 attributes: state.player.as_ref().and_then(|p| p.attributes),
19110 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
19111 ..CombatHud::default()
19112 };
19113 state.apply_combat_hud(&combat);
19114
19115 let xp = state
19116 .player
19117 .as_ref()
19118 .and_then(|p| p.progression_xp.as_ref())
19119 .expect("xp");
19120 assert!((xp.strength - fresh.strength).abs() < 0.001);
19121 assert!(state.progression_curve.is_some());
19122 }
19123
19124 #[test]
19125 fn combat_hud_syncs_known_abilities_and_hotbar() {
19126 use flatland_protocol::CombatHud;
19127
19128 let mut state = sample_state();
19129 let combat = CombatHud {
19130 known_abilities: vec!["unarmed".into(), "fireball".into()],
19131 hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
19132 max_abilities_per_rotation: 4,
19133 ability_id: "short_sword_slash".into(),
19134 ..CombatHud::default()
19135 };
19136 state.apply_combat_hud(&combat);
19137
19138 assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
19139 assert_eq!(state.hotbar_ability(1), Some("fireball"));
19140 assert_eq!(state.hotbar_ability(2), None);
19141 assert_eq!(state.hotbar_ability(3), Some("unarmed"));
19142 assert_eq!(state.max_abilities_per_rotation, 4);
19143 let choices = state.loadout_ability_choices();
19144 assert!(choices.iter().any(|a| a == "short_sword_slash"));
19145 assert!(choices.iter().any(|a| a == "fireball"));
19146 }
19147
19148 #[test]
19149 fn loadout_hotbar_choices_include_inventory_consumables() {
19150 let mut state = sample_state();
19151 state.known_abilities = vec!["unarmed".into()];
19152 state.weapon_ability_id = "unarmed".into();
19153 state.inventory_stacks = vec![flatland_protocol::ItemStack {
19154 template_id: "empty_bottle".into(),
19155 quantity: 1,
19156 item_instance_id: Some(uuid::Uuid::from_u128(9)),
19157 display_name: Some("Glass Bottle of Water".into()),
19158 category: Some("container".into()),
19159 props: [
19160 ("serving".into(), "1".into()),
19161 ("liquid_vessel".into(), "1".into()),
19162 ("serving_holds".into(), "liquid".into()),
19163 ]
19164 .into_iter()
19165 .collect(),
19166 ..Default::default()
19167 }];
19168 state.inventory.insert("empty_bottle".into(), 1);
19169 state.inventory_hints.insert(
19170 "empty_bottle".into(),
19171 InventoryHint {
19172 display_name: "Glass Bottle".into(),
19173 category: "container".into(),
19174 ..Default::default()
19175 },
19176 );
19177
19178 let choices = state.loadout_hotbar_choices();
19179 assert!(choices.iter().any(|c| c.binding == "unarmed"));
19180 let water = choices
19181 .iter()
19182 .find(|c| c.binding == "item:empty_bottle")
19183 .expect("serving bottle binding");
19184 assert_eq!(water.meta.as_deref(), Some("use"));
19185 assert!(water.label.contains("Glass Bottle of Water"));
19186 assert_eq!(state.hotbar_slot_label(1), None, "unbound until set");
19187 state.hotbar = vec![None, None, None, None, Some("item:empty_bottle".into())];
19188 assert_eq!(
19189 state.hotbar_slot_label(5).as_deref(),
19190 Some("Glass Bottle×1")
19191 );
19192 }
19193
19194 #[test]
19195 fn loadout_hotbar_choices_exclude_blueprint_scrolls() {
19196 let mut state = sample_state();
19197 state.known_abilities = vec!["unarmed".into()];
19198 state.weapon_ability_id = "unarmed".into();
19199 state.inventory_stacks = vec![
19200 flatland_protocol::ItemStack {
19201 template_id: "carrot".into(),
19202 quantity: 2,
19203 display_name: Some("Wild Carrot".into()),
19204 category: Some("consumable".into()),
19205 ..Default::default()
19206 },
19207 flatland_protocol::ItemStack {
19208 template_id: "blueprint_dimensional_pouch".into(),
19209 quantity: 1,
19210 display_name: Some("Blueprint — Dimensional Pouch".into()),
19211 category: Some("consumable".into()),
19212 props: [("teaches_blueprint".into(), "craft_dimensional_pouch".into())]
19213 .into_iter()
19214 .collect(),
19215 ..Default::default()
19216 },
19217 ];
19218 state.inventory.insert("carrot".into(), 2);
19219 state.inventory.insert("blueprint_dimensional_pouch".into(), 1);
19220 state.inventory_hints.insert(
19221 "carrot".into(),
19222 InventoryHint {
19223 display_name: "Wild Carrot".into(),
19224 category: "consumable".into(),
19225 ..Default::default()
19226 },
19227 );
19228 state.inventory_hints.insert(
19229 "blueprint_dimensional_pouch".into(),
19230 InventoryHint {
19231 display_name: "Blueprint — Dimensional Pouch".into(),
19232 category: "consumable".into(),
19233 ..Default::default()
19234 },
19235 );
19236
19237 let choices = state.loadout_hotbar_choices();
19238 assert!(choices.iter().any(|c| c.binding == "item:carrot"));
19239 assert!(
19240 choices
19241 .iter()
19242 .all(|c| c.binding != "item:blueprint_dimensional_pouch"),
19243 "recipe scrolls must not appear on the hotbar picker: {choices:?}"
19244 );
19245 }
19246
19247 #[test]
19248 fn storage_store_options_excludes_hand_equipped() {
19249 let mut state = sample_state();
19250 let sword_id = uuid::Uuid::from_u128(11);
19251 let ore_id = uuid::Uuid::from_u128(22);
19252 state.inventory_stacks = vec![
19253 flatland_protocol::ItemStack {
19254 template_id: "short_sword".into(),
19255 quantity: 1,
19256 item_instance_id: Some(sword_id),
19257 display_name: Some("Short Sword".into()),
19258 category: Some("weapon".into()),
19259 ..Default::default()
19260 },
19261 flatland_protocol::ItemStack {
19262 template_id: "iron_ore".into(),
19263 quantity: 5,
19264 item_instance_id: Some(ore_id),
19265 display_name: Some("Iron Ore".into()),
19266 category: Some("resource".into()),
19267 ..Default::default()
19268 },
19269 ];
19270 state.mainhand_template_id = Some("short_sword".into());
19271 state.mainhand_instance_id = Some(sword_id);
19272
19273 let opts = state.storage_store_options();
19274 assert_eq!(opts.len(), 1);
19275 assert_eq!(opts[0].item_instance_id, ore_id);
19276 assert!(state.hand_equipped_instance_ids().contains(&sword_id));
19277 }
19278
19279 #[test]
19280 fn loose_consumable_move_picker_offers_use_and_storage() {
19281 let mut state = sample_state();
19282 let inst = uuid::Uuid::from_u128(77);
19283 state.inventory_stacks = vec![flatland_protocol::ItemStack {
19284 template_id: "carrot".into(),
19285 quantity: 2,
19286 item_instance_id: Some(inst),
19287 props: Default::default(),
19288 status_bindings: Vec::new(),
19289 contents: Vec::new(),
19290 display_name: Some("Wild Carrot".into()),
19291 category: Some("consumable".into()),
19292 base_mass: None,
19293 base_volume: None,
19294 capacity_volume: None,
19295 stackable: Some(true),
19296 world_placeable: None,
19297 worker_lodging_capacity: None,
19298 equip_slot: None,
19299 armor_physical: None,
19300 resists: vec![],
19301 hand_slots: None,
19302 listable: None,
19303 ..Default::default()
19304 }];
19305 state.inventory_hints.insert(
19306 "carrot".into(),
19307 InventoryHint {
19308 display_name: "Wild Carrot".into(),
19309 category: "consumable".into(),
19310 base_mass: Some(0.15),
19311 base_volume: Some(0.3),
19312 capacity_volume: None,
19313 stackable: true,
19314 listable: true,
19315 base_value_copper: None,
19316 },
19317 );
19318 state.show_inventory_menu = true;
19319 state.inventory_menu_index = 0;
19320
19321 let row = state.inventory_selected_row().expect("carrot row");
19322 let mut options = state.move_destinations_for(
19323 &row.from,
19324 row.from_parent_instance_id,
19325 row.stack.item_instance_id,
19326 &row.stack.template_id,
19327 );
19328 if row.from == flatland_protocol::InventoryLocation::Root
19329 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
19330 {
19331 options.insert(
19332 0,
19333 MoveOption {
19334 label: "Use (eat / drink)".into(),
19335 kind: MoveOptionKind::Use,
19336 },
19337 );
19338 }
19339
19340 assert_eq!(
19341 options.first().map(|o| &o.label),
19342 Some(&"Use (eat / drink)".into())
19343 );
19344 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
19345 assert!(options
19346 .iter()
19347 .any(|o| matches!(o.kind, MoveOptionKind::Drop)));
19348 }
19349
19350 #[test]
19351 fn inventory_category_group_order_is_stable() {
19352 assert_eq!(inventory_category_group("weapon").0, "Weapons");
19353 assert_eq!(inventory_category_group("armor").0, "Armor");
19354 assert_eq!(inventory_category_group("consumable").0, "Consumables");
19355 assert_eq!(inventory_category_group("liquid").0, "Consumables");
19356 assert_eq!(inventory_category_group("resource").0, "Resources");
19357 assert_eq!(inventory_category_group("container").0, "Containers");
19358 assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
19359 assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
19360 }
19361
19362 #[test]
19363 fn page_list_index_clamps_without_wrap() {
19364 assert_eq!(page_list_index(0, -1, 25), 0);
19365 assert_eq!(page_list_index(0, 1, 25), 10);
19366 assert_eq!(page_list_index(12, 1, 25), 22);
19367 assert_eq!(page_list_index(22, 1, 25), 24);
19368 assert_eq!(page_list_index(5, 1, 0), 0);
19369 assert_eq!(page_list_index(3, -1, 8), 0);
19370 }
19371
19372 #[test]
19373 fn inventory_filter_hides_non_matching_person_items() {
19374 let mut state = sample_state();
19375 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19376 sword.display_name = Some("Iron Sword".into());
19377 sword.category = Some("weapon".into());
19378 let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
19379 herb.display_name = Some("Wild Herb".into());
19380 herb.category = Some("consumable".into());
19381 state.inventory_stacks = vec![sword, herb];
19382 state.inventory_tab = InventoryTab::OnPerson;
19383 state.inventory_filter = "sword".into();
19384
19385 let rows = state.inventory_selectable_rows();
19386 assert_eq!(rows.len(), 1);
19387 assert_eq!(rows[0].stack.template_id, "iron_sword");
19388
19389 let lines = state.inventory_browser_lines();
19390 assert!(lines.iter().any(|l| matches!(
19391 l,
19392 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
19393 )));
19394 assert!(!lines.iter().any(|l| matches!(
19395 l,
19396 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
19397 )));
19398 }
19399
19400 #[test]
19401 fn list_filter_chars_reject_mac_arrow_glyphs() {
19402 assert!(is_list_filter_char('a'));
19403 assert!(is_list_filter_char(' '));
19404 assert!(is_list_filter_char('-'));
19405 assert!(!is_list_filter_char('\u{F700}'));
19406 assert!(!is_list_filter_char('\u{F701}'));
19407 assert!(!is_list_filter_char('\n'));
19408 }
19409
19410 #[test]
19411 fn ready_tab_keeps_in_progress_craft_after_inputs_spent() {
19412 let mut state = sample_state();
19413 state.craft_tab = CraftTab::Ready;
19414 state.blueprints = vec![BlueprintView {
19415 id: "plank".into(),
19416 label: "Plank".into(),
19417 craft_tier: 1,
19418 craft_ticks: 30,
19419 output: "wood_plank".into(),
19420 output_qty: 1,
19421 output_display_name: "Wood Plank".into(),
19422 station: None,
19423 category: None,
19424 inputs: vec![flatland_protocol::BlueprintIngredientView {
19425 template_id: "oak_log".into(),
19426 quantity: 1,
19427 consumed: true,
19428 display_name: "Oak Log".into(),
19429 }],
19430 required_tools: vec![],
19431 skill: None,
19432 failure_chance: 0.0,
19433 worker_train_copper: 0,
19434 }];
19435 state.inventory.clear();
19437 state.craft_channel_blueprint_id = Some("plank".into());
19438 state.timed_channel = Some(flatland_protocol::TimedChannelHud {
19439 label: "Crafting Plank".into(),
19440 channel: flatland_protocol::TimedChannelKind::Craft,
19441 ticks_remaining: 20,
19442 ticks_total: 30,
19443 ..Default::default()
19444 });
19445
19446 let idxs = state.craft_filtered_indices();
19447 assert_eq!(idxs, vec![0]);
19448 assert!(state.craft_blueprint_in_channel("plank"));
19449
19450 state.timed_channel = None;
19452 state.craft_channel_blueprint_id = None;
19453 assert!(state.craft_filtered_indices().is_empty());
19454 }
19455
19456 #[test]
19457 fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
19458 let mut state = sample_state();
19459 let id_a = uuid::Uuid::from_u128(0xa1);
19460 let id_b = uuid::Uuid::from_u128(0xb2);
19461 let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
19462 sword_a.display_name = Some("Iron Sword".into());
19463 sword_a.category = Some("weapon".into());
19464 sword_a.item_instance_id = Some(id_a);
19465 let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
19466 sword_b.display_name = Some("Iron Sword".into());
19467 sword_b.category = Some("weapon".into());
19468 sword_b.item_instance_id = Some(id_b);
19469 state.inventory_stacks = vec![sword_a, sword_b];
19470 state.inventory_tab = InventoryTab::OnPerson;
19471
19472 let lines = state.inventory_browser_lines();
19473 let items: Vec<_> = lines
19474 .iter()
19475 .filter_map(|l| match l {
19476 InventoryBrowserLine::Item {
19477 title,
19478 instance_tooltip,
19479 ..
19480 } => Some((title.clone(), instance_tooltip.clone())),
19481 _ => None,
19482 })
19483 .collect();
19484 assert_eq!(items.len(), 2);
19485 for (title, tip) in &items {
19486 assert!(
19487 !title.contains('#'),
19488 "title should not show instance suffix: {title}"
19489 );
19490 assert!(
19491 tip.is_some(),
19492 "two identical rows should expose instance on hover"
19493 );
19494 }
19495
19496 state.inventory_stacks.pop();
19497 let lines = state.inventory_browser_lines();
19498 let one = lines.iter().find_map(|l| match l {
19499 InventoryBrowserLine::Item {
19500 title,
19501 instance_tooltip,
19502 ..
19503 } => Some((title.clone(), instance_tooltip.clone())),
19504 _ => None,
19505 });
19506 let (title, tip) = one.expect("one sword row");
19507 assert!(!title.contains('#'));
19508 assert!(tip.is_none(), "single row should not need instance tooltip");
19509 }
19510
19511 #[test]
19512 fn inventory_person_rows_group_by_category() {
19513 let mut state = sample_state();
19514 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19515 sword.category = Some("weapon".into());
19516 sword.display_name = Some("Iron Sword".into());
19517 let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
19518 ore.category = Some("resource".into());
19519 ore.display_name = Some("Iron Ore".into());
19520 let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
19521 potion.category = Some("consumable".into());
19522 potion.display_name = Some("Health Potion".into());
19523 state.inventory_stacks = vec![ore, potion, sword];
19524 state.inventory_tab = InventoryTab::OnPerson;
19525
19526 let lines = state.inventory_browser_lines();
19527 let labels: Vec<&str> = lines
19528 .iter()
19529 .filter_map(|l| match l {
19530 InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
19531 _ => None,
19532 })
19533 .collect();
19534 assert!(
19535 labels.iter().any(|s| s.contains("Weapons")),
19536 "expected Weapons group: {labels:?}"
19537 );
19538 assert!(labels.iter().any(|s| s.contains("Consumables")));
19539 assert!(labels.iter().any(|s| s.contains("Resources")));
19540
19541 let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
19542 let consumable_pos = labels
19543 .iter()
19544 .position(|s| s.contains("Consumables"))
19545 .unwrap();
19546 let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
19547 assert!(weapon_pos < consumable_pos);
19548 assert!(consumable_pos < resource_pos);
19549 }
19550
19551 #[test]
19552 fn inventory_tab_cycle_resets_selection() {
19553 let mut state = sample_state();
19554 state.inventory_tab = InventoryTab::OnPerson;
19555 state.inventory_menu_index = 3;
19556 state.inventory_tab = state.inventory_tab.cycle(true);
19557 assert_eq!(state.inventory_tab, InventoryTab::Nearby);
19558 assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
19560 assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
19561 assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
19562 assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
19563 }
19564
19565 #[test]
19566 fn parse_bank_copper_amount_blank_and_zero_mean_all() {
19567 assert_eq!(parse_bank_copper_amount(""), Some(0));
19568 assert_eq!(parse_bank_copper_amount(" "), Some(0));
19569 assert_eq!(parse_bank_copper_amount("0"), Some(0));
19570 assert_eq!(parse_bank_copper_amount("250"), Some(250));
19571 assert_eq!(parse_bank_copper_amount("nope"), None);
19572 }
19573
19574 #[test]
19575 fn parse_storage_quantity_blank_and_zero_mean_all() {
19576 assert_eq!(parse_storage_quantity(""), Some(None));
19577 assert_eq!(parse_storage_quantity(" "), Some(None));
19578 assert_eq!(parse_storage_quantity("0"), Some(None));
19579 assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
19580 assert_eq!(parse_storage_quantity("nope"), None);
19581 }
19582
19583 #[test]
19584 fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
19585 assert!(worker_error_is_hud_noise("path stuck — repathing"));
19586 assert!(worker_error_is_hud_noise(
19587 "path stuck — nudged clear, repathing"
19588 ));
19589 assert!(worker_error_is_hud_noise(
19590 "returned to lodging after path failures"
19591 ));
19592 assert!(!worker_error_is_hud_noise(
19594 "path stuck — no lodging to reset to"
19595 ));
19596 assert!(!worker_error_is_hud_noise(
19597 "cannot reach Eli — idling"
19598 ));
19599 assert!(worker_error_is_transient(
19600 "storage full; continuing route"
19601 ));
19602 assert!(!worker_error_is_transient(
19603 "storage full (Food Bank) — free chest space or reassign deposit"
19604 ));
19605 assert!(!worker_error_is_hud_noise(
19606 "storage full (Food Bank) — free chest space or reassign deposit"
19607 ));
19608 }
19609
19610 #[test]
19611 fn leaving_building_restores_outdoor_z_bands() {
19612 use flatland_protocol::{InteriorMapView, ZPlatformView};
19613
19614 let mut state = sample_state();
19615 state.z_platforms.clear();
19616 state.z_transitions.clear();
19617 state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
19618 state.interior_map = Some(InteriorMapView {
19619 building_id: "broker_hut".into(),
19620 blueprint_id: "broker_hut".into(),
19621 background_color: "#000".into(),
19622 default_floor_color: None,
19623 floor_height_m: 3.0,
19624 z_platforms: vec![ZPlatformView {
19625 id: "floor_0".into(),
19626 z: 0.0,
19627 x0: 0.0,
19628 y0: 0.0,
19629 x1: 8.0,
19630 y1: 8.0,
19631 }],
19632 z_transitions: vec![],
19633 rooms: vec![],
19634 room_doors: vec![],
19635 });
19636 state.sync_interior_map_context();
19637 assert_eq!(
19638 state.z_platforms.len(),
19639 1,
19640 "indoors installs interior platforms"
19641 );
19642 assert!(state.z_bands_outdoor_backup.is_some());
19643
19644 state.player.as_mut().unwrap().inside_building = None;
19645 state.sync_interior_map_context();
19646 assert!(
19647 state.z_platforms.is_empty(),
19648 "leaving must restore outdoor bands (empty), not leave interior platforms"
19649 );
19650 assert!(state.z_bands_outdoor_backup.is_none());
19651 assert!(state.interior_map.is_none());
19652 }
19653
19654 #[test]
19655 fn resource_node_route_label_prefers_friendly_label_with_suffix() {
19656 let node = ResourceNodeView {
19657 id: "crop-carrot-1_copy10".into(),
19658 label: "crop-carrot-1_copy10".into(),
19659 x: 0.0,
19660 y: 0.0,
19661 z: 0.0,
19662 item_template: "carrot".into(),
19663 state: ResourceNodeState::Available,
19664 blocking: false,
19665 blocking_radius_m: 0.5,
19666 harvest_off: false,
19667 tile_id: None,
19668 yaw: 0.0,
19669 pitch: 0.0,
19670 roll: 0.0,
19671 draw_scale: 1.0,
19672 sprite_mode: None,
19673 growth_progress: None,
19674 presentation_state: None,
19675 channel_start_tick: None,
19676 channel_end_tick: None,
19677 harvest_drop_templates: vec![],
19678 };
19679 let label = super::resource_node_route_label(&node);
19680 assert!(label.starts_with("Carrot ("), "got {label}");
19681 assert!(label.ends_with(')'), "got {label}");
19682
19683 let mut named = node;
19684 named.label = "Sweet Pad".into();
19685 named.id = "crop-carrot-a3f2b1c0".into();
19686 assert_eq!(super::resource_node_route_label(&named), "Sweet Pad (b1c0)");
19687 }
19688
19689 #[test]
19690 fn plot_public_label_uses_owner_zone_and_label() {
19691 let plot = flatland_protocol::PropertyPlotView {
19692 plot_id: uuid::Uuid::nil(),
19693 property_zone_id: "zone_a".into(),
19694 zone_label: Some("Starter Town East 1".into()),
19695 deed_instance_id: uuid::Uuid::nil(),
19696 x0: 0.0,
19697 y0: 0.0,
19698 x1: 4.0,
19699 y1: 4.0,
19700 upkeep_copper_per_day: 1,
19701 arrears_days: 0,
19702 is_mine: true,
19703 may_farm: true,
19704 purchase_basis_copper: 0,
19705 farm_public: false,
19706 public_tax_discount_bps: 0,
19707 farm_allow: vec![],
19708 owner_character_id: None,
19709 owner_label: Some("Madsin".into()),
19710 building_id: None,
19711 plot_code: "xyz1234a".into(),
19712 label: "Food Pad".into(),
19713 };
19714 assert_eq!(
19715 super::plot_public_label(&plot),
19716 "Madsin — Starter Town East 1 — Food Pad"
19717 );
19718 }
19719
19720 #[test]
19721 fn plot_public_label_uses_size_when_label_and_code_blank() {
19722 let plot = flatland_protocol::PropertyPlotView {
19723 plot_id: uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap(),
19724 property_zone_id: String::new(),
19725 zone_label: None,
19726 deed_instance_id: uuid::Uuid::nil(),
19727 x0: 10.0,
19728 y0: 20.0,
19729 x1: 18.0,
19730 y1: 28.0,
19731 upkeep_copper_per_day: 1,
19732 arrears_days: 0,
19733 is_mine: true,
19734 may_farm: true,
19735 purchase_basis_copper: 0,
19736 farm_public: false,
19737 public_tax_discount_bps: 0,
19738 farm_allow: vec![],
19739 owner_character_id: None,
19740 owner_label: None,
19741 building_id: None,
19742 plot_code: String::new(),
19743 label: String::new(),
19744 };
19745 assert_eq!(super::plot_public_label(&plot), "Homestead — Plot (8×8 m)");
19746 assert!(!super::plot_public_label(&plot).contains("19fe35f"));
19747 }
19748
19749 #[test]
19750 fn plot_stop_label_prefers_view_over_hex() {
19751 let plot_id = uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap();
19752 let plot = flatland_protocol::PropertyPlotView {
19753 plot_id,
19754 property_zone_id: "zone_a".into(),
19755 zone_label: Some("Starter Town East".into()),
19756 deed_instance_id: uuid::Uuid::nil(),
19757 x0: 0.0,
19758 y0: 0.0,
19759 x1: 4.0,
19760 y1: 4.0,
19761 upkeep_copper_per_day: 1,
19762 arrears_days: 0,
19763 is_mine: true,
19764 may_farm: true,
19765 purchase_basis_copper: 0,
19766 farm_public: false,
19767 public_tax_discount_bps: 0,
19768 farm_allow: vec![],
19769 owner_character_id: None,
19770 owner_label: Some("Madsin".into()),
19771 building_id: None,
19772 plot_code: "xyz1234a".into(),
19773 label: "Food Pad".into(),
19774 };
19775 assert_eq!(
19776 super::plot_stop_label(&[plot.clone()], plot_id),
19777 "Madsin — Starter Town East — Food Pad"
19778 );
19779 let missing = uuid::Uuid::parse_str("1a001590-0000-4000-8000-000000000002").unwrap();
19780 assert_eq!(super::plot_stop_label(&[plot], missing), "plot 1a001590");
19781 }
19782}