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 CRAFT_BATCH_SELECT_CAP: u32 = 999;
116const WORKER_STEP_HOLD: Duration = Duration::from_millis(1200);
118const WORKER_ERROR_HOLD: Duration = Duration::from_secs(45);
120const WORKER_HEALTH_RING_HOLD: Duration = Duration::from_secs(6);
122const WORKER_HIRE_PENDING_TIMEOUT: Duration = Duration::from_secs(15);
124
125#[derive(Debug, Clone, Default)]
127pub struct InventoryHint {
128 pub display_name: String,
129 pub category: String,
130 pub base_mass: Option<f32>,
131 pub base_volume: Option<f32>,
132 pub capacity_volume: Option<f32>,
133 pub stackable: bool,
134 pub listable: bool,
136 pub base_value_copper: Option<u32>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct LoadoutHotbarChoice {
143 pub binding: String,
145 pub label: String,
147 pub meta: Option<String>,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
153pub enum RotationEditorMode {
154 #[default]
155 List,
156 EditSequence,
157 PickAbility,
158 EditLabel,
159}
160
161#[derive(Debug, Clone, Default)]
163pub struct RotationEditorState {
164 pub mode: RotationEditorMode,
165 pub list_index: usize,
166 pub ability_index: usize,
167 pub picker_index: usize,
168 pub draft: Option<RotationPreset>,
169 pub label_buffer: String,
170}
171
172impl RotationEditorState {
173 pub fn reset(&mut self) {
174 *self = Self::default();
175 }
176}
177
178pub const CONTAINER_RANGE_M: f32 = 3.0;
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum InventorySection {
188 Worn,
190 Person,
192 Nearby,
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
198pub enum InventoryTab {
199 #[default]
200 OnPerson,
201 Nearby,
202}
203
204impl InventoryTab {
205 pub fn label(self) -> &'static str {
206 match self {
207 Self::OnPerson => "On person",
208 Self::Nearby => "Nearby storage",
209 }
210 }
211
212 pub fn cycle(self, forward: bool) -> Self {
213 match (self, forward) {
214 (Self::OnPerson, true) | (Self::OnPerson, false) => Self::Nearby,
215 (Self::Nearby, true) | (Self::Nearby, false) => Self::OnPerson,
216 }
217 }
218}
219
220pub const LIST_PAGE_SIZE: usize = 10;
222
223pub fn list_label_matches(haystack: &str, filter: &str) -> bool {
225 if filter.is_empty() {
226 return true;
227 }
228 haystack
229 .to_ascii_lowercase()
230 .contains(&filter.to_ascii_lowercase())
231}
232
233pub fn is_list_filter_char(ch: char) -> bool {
236 match ch {
237 ' '..='~' => true,
238 c if c.is_alphanumeric() => true,
239 _ => false,
240 }
241}
242
243pub fn page_list_index(index: usize, pages: i32, len: usize) -> usize {
245 if len == 0 {
246 return 0;
247 }
248 let page = LIST_PAGE_SIZE as i32;
249 let next = index as i32 + pages * page;
250 next.clamp(0, (len as i32) - 1) as usize
251}
252
253pub fn step_filtered_index(
255 index: usize,
256 delta: i32,
257 len: usize,
258 pred: impl Fn(usize) -> bool,
259) -> usize {
260 if len == 0 {
261 return 0;
262 }
263 let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
264 if matching.is_empty() {
265 return index.min(len - 1);
266 }
267 let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
268 let next = (pos as i32 + delta).rem_euclid(matching.len() as i32) as usize;
269 matching[next]
270}
271
272pub fn page_filtered_index(
274 index: usize,
275 pages: i32,
276 len: usize,
277 pred: impl Fn(usize) -> bool,
278) -> usize {
279 if len == 0 {
280 return 0;
281 }
282 let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
283 if matching.is_empty() {
284 return index.min(len - 1);
285 }
286 let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
287 let next = page_list_index(pos, pages, matching.len());
288 matching[next]
289}
290
291pub fn inventory_category_group(category: &str) -> (&'static str, u8) {
293 match category {
294 "weapon" | "ammo" => ("Weapons", 0),
295 "armor" | "shield" | "offhand" => ("Armor", 1),
296 "consumable" | "liquid" | "bulk" => ("Consumables", 2),
297 "resource" | "harvest_node" | "seed" => ("Resources", 3),
298 "container" | "lodging" => ("Containers", 4),
299 "currency" | "key" => ("Currency & keys", 5),
300 "tool" | "misc" | "furniture" | "quest" | "document" => ("Gear & misc", 6),
301 _ => ("Other", 7),
302 }
303}
304
305pub fn category_default_listable(category: &str) -> bool {
307 !matches!(
308 category,
309 "currency" | "harvest_node" | "key" | "quest" | "document" | "lodging"
310 )
311}
312
313fn vessel_holds_category(stack: &flatland_protocol::ItemStack, category: Option<&str>) -> bool {
314 let cat = category.unwrap_or("");
315 if let Some(holds) = stack.props.get("serving_holds") {
316 return holds.split(',').any(|p| {
317 let p = p.trim();
318 p == cat
319 || (cat == "liquid" && p == "liquid")
320 || (cat == "bulk" && p == "bulk")
321 || (matches!(cat, "consumable") && p == "food")
322 });
323 }
324 match cat {
326 "bulk" => stack.props.get("bulk_vessel").is_some_and(|v| v == "1"),
327 "liquid" => stack.props.get("liquid_vessel").is_some_and(|v| v == "1"),
328 _ => false,
329 }
330}
331
332fn serving_capacity_of(stack: &flatland_protocol::ItemStack) -> u32 {
333 stack
334 .props
335 .get("serving_capacity")
336 .and_then(|s| s.parse().ok())
337 .unwrap_or(0)
338}
339
340fn payload_units_in_vessel(stack: &flatland_protocol::ItemStack) -> u32 {
341 stack.contents.iter().map(|c| c.quantity).sum()
342}
343
344fn is_serving_vessel_stack(stack: &flatland_protocol::ItemStack) -> bool {
345 stack.props.get("serving").is_some_and(|v| v == "1")
346 || stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
347 || stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
348 || stack.props.contains_key("serving_holds")
349 || stack.props.contains_key("serving_capacity")
350}
351
352fn vessel_free_room_for_payload(
353 stack: &flatland_protocol::ItemStack,
354 payload_id: &str,
355 payload_category: Option<&str>,
356) -> u32 {
357 if !is_serving_vessel_stack(stack) || !vessel_holds_category(stack, payload_category) {
358 return 0;
359 }
360 let primary = stack.contents.iter().find(|c| c.quantity > 0);
361 let compatible = primary.is_none_or(|c| c.template_id == payload_id);
362 if !compatible {
363 return 0;
364 }
365 let cap = serving_capacity_of(stack);
366 let used = payload_units_in_vessel(stack);
367 let per_shell = cap.saturating_sub(used);
368 if per_shell == 0 {
369 return 0;
370 }
371 let shells = if stack.contents.is_empty() {
373 stack.quantity.max(1)
374 } else {
375 1
376 };
377 per_shell.saturating_mul(shells)
378}
379
380fn drain_payload_from_stacks(
381 stacks: &mut [flatland_protocol::ItemStack],
382 template_id: &str,
383 remaining: &mut u32,
384) {
385 if *remaining == 0 {
386 return;
387 }
388 for stack in stacks.iter_mut() {
389 if *remaining == 0 {
390 return;
391 }
392 if stack.template_id == template_id && stack.quantity > 0 {
393 let take = (*remaining).min(stack.quantity);
394 stack.quantity -= take;
395 *remaining -= take;
396 }
397 drain_payload_from_stacks(&mut stack.contents, template_id, remaining);
398 stack.contents.retain(|c| c.quantity > 0);
400 }
401}
402
403fn vessel_room_for_payload_in_stacks(
404 stacks: &[flatland_protocol::ItemStack],
405 payload_id: &str,
406 payload_category: Option<&str>,
407) -> u32 {
408 let mut room = 0u32;
409 for stack in stacks {
410 room = room.saturating_add(vessel_free_room_for_payload(
411 stack,
412 payload_id,
413 payload_category,
414 ));
415 room = room.saturating_add(vessel_room_for_payload_in_stacks(
416 &stack.contents,
417 payload_id,
418 payload_category,
419 ));
420 }
421 room
422}
423
424#[derive(Debug, Clone)]
426pub struct CraftVesselLine {
427 pub label: String,
428 pub holds: String,
429 pub capacity: u32,
430 pub used: u32,
431 pub free: u32,
432 pub quantity: u32,
433 pub accepts_output: bool,
434 pub location: &'static str,
435}
436
437#[derive(Debug, Clone)]
439pub struct CraftVesselStatus {
440 pub needs_vessel: bool,
441 pub output_label: String,
442 pub need_units: u32,
443 pub free_after_inputs: u32,
444 pub ok: bool,
445 pub vessels: Vec<CraftVesselLine>,
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450pub enum CraftTab {
451 Ready,
452 Favorites,
453 Recent,
454 Tier(u32),
455}
456
457impl CraftTab {
458 pub fn label(self) -> String {
459 match self {
460 Self::Ready => "Ready".into(),
461 Self::Favorites => "★".into(),
462 Self::Recent => "Recent".into(),
463 Self::Tier(n) => format!("T{n}"),
464 }
465 }
466}
467
468pub fn npc_market_dump_unit_estimate_copper(base_value: u32) -> Option<u32> {
470 if base_value == 0 {
471 return None;
472 }
473 let unit = ((base_value as f32) * 0.5).floor() as u32;
474 if unit == 0 {
475 return None;
476 }
477 Some(((unit as u64).saturating_mul(9500) / 10_000).max(1) as u32)
478}
479
480fn parse_bank_copper_amount(input: &str) -> Option<u64> {
482 let s = input.trim();
483 if s.is_empty() {
484 return Some(0);
485 }
486 s.parse::<u64>().ok()
487}
488
489fn parse_storage_quantity(input: &str) -> Option<Option<u32>> {
491 let s = input.trim();
492 if s.is_empty() || s == "0" {
493 return Some(None);
494 }
495 let n = s.parse::<u32>().ok()?;
496 if n == 0 {
497 return Some(None);
498 }
499 Some(Some(n))
500}
501
502fn storage_stack_label(stack: &flatland_protocol::ItemStack) -> String {
503 let name = stack
504 .display_name
505 .as_deref()
506 .unwrap_or(stack.template_id.as_str());
507 if stack.quantity > 1 {
508 format!("{name} ×{}", stack.quantity)
509 } else {
510 name.to_string()
511 }
512}
513
514pub fn body_slot_label(slot: BodySlot) -> &'static str {
517 match slot {
518 BodySlot::Head => "Head",
519 BodySlot::Chest => "Chest",
520 BodySlot::Forearms => "Forearms",
521 BodySlot::Legs => "Legs",
522 BodySlot::Feet => "Feet",
523 BodySlot::Cloak => "Cloak",
524 BodySlot::Back => "Back",
525 BodySlot::Waist => "Waist",
526 BodySlot::Earrings => "Earrings",
527 BodySlot::Necklace => "Necklace",
528 BodySlot::Eyeglasses => "Eyeglasses",
529 BodySlot::RingLeft1 => "Ring L1",
530 BodySlot::RingLeft2 => "Ring L2",
531 BodySlot::RingRight1 => "Ring R1",
532 BodySlot::RingRight2 => "Ring R2",
533 }
534}
535
536fn grant_target_matches_mode(stack: &flatland_protocol::ItemStack, mode: &str) -> bool {
537 let cat = stack.category.as_deref().unwrap_or("");
538 match mode {
539 "while_equipped" => {
540 stack.equip_slot.is_some()
541 || cat == "weapon"
542 || cat == "shield"
543 || cat == "offhand"
544 || cat == "armor"
545 }
546 _ => cat == "weapon" || cat == "ammo" || stack.props.contains_key("weapon_ability_id"),
547 }
548}
549
550fn grant_tags_match(stack: &flatland_protocol::ItemStack, grant_tags: &[&str]) -> bool {
551 if grant_tags.is_empty() {
552 return true;
553 }
554 let target_tags: Vec<&str> = stack
555 .props
556 .get("allowed_enchant_tags")
557 .map(|s| {
558 s.split(',')
559 .map(str::trim)
560 .filter(|t| !t.is_empty())
561 .collect()
562 })
563 .unwrap_or_default();
564 if target_tags.is_empty() {
565 return true;
566 }
567 grant_tags.iter().any(|t| target_tags.contains(t))
568}
569
570pub const DEFAULT_TICK_HZ: u32 = 30;
572
573pub fn format_binding_ttl(
575 binding: &flatland_protocol::ItemStatusBinding,
576 tick: u64,
577 tick_hz: u32,
578) -> String {
579 let Some(expires) = binding.expires_at_tick else {
580 return "permanent".into();
581 };
582 let hz = tick_hz.max(1) as f32;
583 let remaining = expires.saturating_sub(tick) as f32 / hz;
584 if remaining <= 0.0 {
585 return "expired".into();
586 }
587 if remaining >= 120.0 {
588 format!("{:.0}m left", remaining / 60.0)
589 } else if remaining >= 10.0 {
590 format!("{remaining:.0}s left")
591 } else {
592 format!("{remaining:.1}s left")
593 }
594}
595
596pub fn format_binding_mode(mode: flatland_protocol::ItemStatusBindingMode) -> &'static str {
597 match mode {
598 flatland_protocol::ItemStatusBindingMode::OnHit => "on hit",
599 flatland_protocol::ItemStatusBindingMode::WhileEquipped => "while equipped",
600 }
601}
602
603pub fn format_status_bindings_suffix(
605 bindings: &[flatland_protocol::ItemStatusBinding],
606 tick: u64,
607 tick_hz: u32,
608) -> String {
609 if bindings.is_empty() {
610 return String::new();
611 }
612 let parts: Vec<String> = bindings
613 .iter()
614 .map(|b| {
615 format!(
616 "{} ({}, {})",
617 b.effect_id,
618 format_binding_mode(b.mode),
619 format_binding_ttl(b, tick, tick_hz)
620 )
621 })
622 .collect();
623 format!(" · {}", parts.join("; "))
624}
625
626#[derive(Debug, Clone, Copy, PartialEq, Eq)]
627pub enum EquipPaperdollRow {
628 Body { slot: BodySlot, filled: bool },
629 Mainhand { filled: bool },
630 Offhand { filled: bool, locked: bool },
631}
632
633pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
634 let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
635 .iter()
636 .map(|slot| EquipPaperdollRow::Body {
637 slot: *slot,
638 filled: state.worn.contains_key(slot),
639 })
640 .collect();
641 let two_hand = state.mainhand_hand_slots >= 2;
642 rows.push(EquipPaperdollRow::Mainhand {
643 filled: state.mainhand_template_id.is_some(),
644 });
645 rows.push(EquipPaperdollRow::Offhand {
646 filled: state.offhand_template_id.is_some(),
647 locked: two_hand,
648 });
649 rows
650}
651
652fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
653 for stack in &state.inventory_stacks {
654 let matches = stack
655 .equip_slot
656 .map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
657 .unwrap_or(false)
658 || guess_body_slot(&stack.template_id) == Some(slot);
659 if matches {
660 return stack.item_instance_id;
661 }
662 }
663 None
664}
665
666fn is_client_ring(slot: BodySlot) -> bool {
667 matches!(
668 slot,
669 BodySlot::RingLeft1 | BodySlot::RingLeft2 | BodySlot::RingRight1 | BodySlot::RingRight2
670 )
671}
672
673fn first_inventory_weapon(state: &GameState) -> Option<String> {
674 for stack in &state.inventory_stacks {
675 if stack.category.as_deref() == Some("weapon") {
676 return Some(stack.template_id.clone());
677 }
678 }
679 None
680}
681
682fn first_inventory_offhand(state: &GameState) -> Option<String> {
683 for stack in &state.inventory_stacks {
684 let cat = stack.category.as_deref().unwrap_or("");
685 if matches!(cat, "shield" | "offhand") {
686 return Some(stack.template_id.clone());
687 }
688 }
689 None
690}
691
692fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
695 if template_id.contains("backpack") {
696 Some(BodySlot::Back)
697 } else if template_id.contains("belt") {
698 Some(BodySlot::Waist)
699 } else if template_id.contains("cloak") || template_id.contains("cape") {
700 Some(BodySlot::Cloak)
701 } else if template_id.contains("cap")
702 || template_id.contains("hat")
703 || template_id.contains("helm")
704 {
705 Some(BodySlot::Head)
706 } else if template_id.contains("shirt")
707 || template_id.contains("robe")
708 || template_id.contains("vest")
709 || template_id.contains("chest")
710 || template_id.contains("jerkin")
711 {
712 Some(BodySlot::Chest)
713 } else if template_id.contains("sleeves")
714 || template_id.contains("gloves")
715 || template_id.contains("gauntlets")
716 {
717 Some(BodySlot::Forearms)
718 } else if template_id.contains("pants") || template_id.contains("leggings") {
719 Some(BodySlot::Legs)
720 } else if template_id.contains("boots") || template_id.contains("shoes") {
721 Some(BodySlot::Feet)
722 } else if template_id.contains("earring") {
723 Some(BodySlot::Earrings)
724 } else if template_id.contains("necklace") || template_id.contains("amulet") {
725 Some(BodySlot::Necklace)
726 } else if template_id.contains("glass")
727 || template_id.contains("spectacles")
728 || template_id.contains("goggles")
729 {
730 Some(BodySlot::Eyeglasses)
731 } else if template_id.contains("ring") {
732 Some(BodySlot::RingLeft1)
733 } else {
734 None
735 }
736}
737
738#[derive(Debug, Clone)]
740pub struct InventoryRow {
741 pub depth: usize,
742 pub stack: flatland_protocol::ItemStack,
743 pub from: flatland_protocol::InventoryLocation,
745 pub from_parent_instance_id: Option<uuid::Uuid>,
747 pub is_equip_shell: bool,
749 pub is_chest_shell: bool,
751 pub section: InventorySection,
752}
753
754#[derive(Debug, Clone)]
756pub struct InventoryRowView {
757 pub depth: usize,
758 pub text: String,
760 pub title: String,
762 pub mass_kg: Option<f32>,
763 pub volume: Option<(f32, f32)>,
764 pub instance_tooltip: Option<String>,
766}
767
768#[derive(Debug, Clone)]
770pub enum InventoryBrowserLine {
771 Section(String),
772 SlotLabel(String),
773 Hint(String),
774 Blank,
775 Item {
776 selectable_index: usize,
777 selected: bool,
778 depth: usize,
779 text: String,
780 title: String,
781 mass_kg: Option<f32>,
782 volume: Option<(f32, f32)>,
783 instance_tooltip: Option<String>,
784 },
785}
786
787#[derive(Debug, Clone, PartialEq, Eq, Default)]
789pub enum BankUiMode {
790 #[default]
791 Menu,
792 DepositAmount {
793 input: String,
794 },
795 WithdrawAmount {
796 input: String,
797 },
798 TransferName {
799 input: String,
800 },
801 TransferAmount {
802 to_name: String,
803 input: String,
804 },
805}
806
807#[derive(Debug, Clone, PartialEq, Eq, Default)]
809pub enum StorageUiMode {
810 #[default]
811 Menu,
812 StorePick { index: usize },
814 StoreAmount {
816 pick_index: usize,
817 item_instance_id: uuid::Uuid,
818 label: String,
819 max_qty: u32,
820 input: String,
821 },
822 TakePick { index: usize },
824 TakeAmount {
826 pick_index: usize,
827 item_instance_id: uuid::Uuid,
828 label: String,
829 max_qty: u32,
830 input: String,
831 },
832 ShipPick {
834 dest_building_id: String,
835 dest_label: String,
836 index: usize,
837 },
838 ShipAmount {
840 dest_building_id: String,
841 dest_label: String,
842 pick_index: usize,
843 item_instance_id: uuid::Uuid,
844 label: String,
845 max_qty: u32,
846 input: String,
847 },
848}
849
850#[derive(Debug, Clone, PartialEq, Eq)]
852pub enum MarketListSourceKind {
853 Person,
854 TownStorage { building_id: String },
855}
856
857#[derive(Debug, Clone, PartialEq, Eq, Default)]
859pub enum MarketUiMode {
860 #[default]
861 Browse,
862 ListSource { index: usize },
864 ListPick {
866 source: MarketListSourceKind,
867 index: usize,
868 },
869 ListAmount {
871 source: MarketListSourceKind,
872 pick_index: usize,
873 item_instance_id: uuid::Uuid,
874 template_id: String,
875 label: String,
876 max_qty: u32,
877 input: String,
878 },
879 ListPricingMode {
881 source: MarketListSourceKind,
882 pick_index: usize,
883 item_instance_id: uuid::Uuid,
884 template_id: String,
885 label: String,
886 quantity: Option<u32>,
887 max_qty: u32,
888 index: usize,
890 },
891 ListPrice {
893 source: MarketListSourceKind,
894 pick_index: usize,
895 item_instance_id: uuid::Uuid,
896 template_id: String,
897 label: String,
898 quantity: Option<u32>,
900 max_qty: u32,
901 input: String,
902 },
903}
904
905#[derive(Debug, Clone)]
907pub struct StoragePickOption {
908 pub item_instance_id: uuid::Uuid,
909 pub template_id: String,
910 pub label: String,
911 pub quantity: u32,
912 pub category: String,
914}
915
916#[derive(Debug, Clone)]
919pub struct NearbyContainer {
920 pub view: flatland_protocol::PlacedContainerView,
921 pub distance_m: f32,
922 pub rows: Vec<InventoryRow>,
923}
924
925#[derive(Debug, Clone)]
927pub struct KeychainEntry {
928 pub stack: flatland_protocol::ItemStack,
929 pub stowed: bool,
930}
931
932#[derive(Debug, Clone)]
934pub struct MoveOption {
935 pub label: String,
936 pub kind: MoveOptionKind,
937 pub volume: Option<(f32, f32)>,
939}
940
941impl MoveOption {
942 fn action(label: impl Into<String>, kind: MoveOptionKind) -> Self {
943 Self {
944 label: label.into(),
945 kind,
946 volume: None,
947 }
948 }
949
950 pub fn volume_usage_label(&self) -> Option<String> {
952 self.volume
953 .map(|(used, cap)| format_container_volume_usage(used, cap))
954 }
955}
956
957pub fn format_container_volume_usage(used: f32, cap: f32) -> String {
959 let used = used.max(0.0);
960 let cap = cap.max(0.0);
961 let free = (cap - used).max(0.0);
962 format!("vol {used:.0}/{cap:.0} ({free:.0} free)")
963}
964
965#[derive(Debug, Clone, PartialEq)]
966pub enum MoveOptionKind {
967 Move {
968 location: flatland_protocol::InventoryLocation,
969 parent_instance_id: Option<uuid::Uuid>,
970 },
971 PickupPlaced {
973 container_id: String,
974 nest_location: flatland_protocol::InventoryLocation,
975 nest_parent_instance_id: Option<uuid::Uuid>,
976 },
977 RelocatePlaced {
979 container_id: String,
980 },
981 Use,
983 GrantApply,
985 Drop,
986 SellPlotToCrown {
988 plot_id: uuid::Uuid,
989 },
990 Cancel,
991}
992
993#[derive(Debug, Clone, PartialEq)]
995pub enum FarmAccessRow {
996 PublicToggle,
997 PublicDiscount,
998 AllowRemove {
999 character_id: uuid::Uuid,
1000 label: String,
1001 tax_discount_bps: u32,
1002 },
1003 NearbyAdd {
1004 name: String,
1005 },
1006}
1007
1008#[derive(Debug, Clone)]
1010pub struct GrantTargetPicker {
1011 pub grant_instance_id: uuid::Uuid,
1012 pub grant_label: String,
1013 pub effect_id: String,
1014 pub mode: String,
1015 pub options: Vec<GrantTargetOption>,
1016 pub filter: String,
1017 pub filter_focused: bool,
1018}
1019
1020#[derive(Debug, Clone)]
1021pub struct GrantTargetOption {
1022 pub label: String,
1023 pub target_instance_id: uuid::Uuid,
1024}
1025
1026#[derive(Debug, Clone)]
1028pub struct MovePicker {
1029 pub item_instance_id: uuid::Uuid,
1030 pub from: flatland_protocol::InventoryLocation,
1031 pub item_label: String,
1032 pub template_id: String,
1033 pub stack_quantity: u32,
1034 pub quantity: u32,
1035 pub options: Vec<MoveOption>,
1036 pub filter: String,
1037 pub filter_focused: bool,
1038}
1039
1040#[derive(Debug, Clone)]
1042pub struct DestroyPicker {
1043 pub item_instance_id: uuid::Uuid,
1044 pub from: flatland_protocol::InventoryLocation,
1045 pub item_label: String,
1046 pub stack_quantity: u32,
1047 pub quantity: u32,
1048}
1049
1050#[derive(Debug, Clone)]
1052pub struct WorkerGiveOption {
1053 pub item_instance_id: uuid::Uuid,
1054 pub label: String,
1055 pub quantity: u32,
1056 pub template_id: String,
1057}
1058
1059#[derive(Debug, Clone)]
1061pub struct WorkerGivePicker {
1062 pub worker_instance_id: String,
1063 pub worker_label: String,
1064 pub options: Vec<WorkerGiveOption>,
1065}
1066
1067#[derive(Debug, Clone)]
1069pub struct WorkerGiveTargetOption {
1070 pub instance_id: String,
1071 pub label: String,
1072 pub distance_m: f32,
1073}
1074
1075#[derive(Debug, Clone)]
1077pub struct WorkerGiveTargetPicker {
1078 pub item_instance_id: uuid::Uuid,
1079 pub item_label: String,
1080 pub quantity: Option<u32>,
1081 pub options: Vec<WorkerGiveTargetOption>,
1082}
1083
1084#[derive(Debug, Clone)]
1086pub struct WorkerTakePicker {
1087 pub worker_instance_id: String,
1088 pub worker_label: String,
1089 pub options: Vec<WorkerGiveOption>,
1090 pub quantity: u32,
1092}
1093
1094pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
1096
1097#[derive(Debug, Clone)]
1099pub struct WorkerTeachOption {
1100 pub blueprint_id: String,
1101 pub label: String,
1102 pub cost_copper: u64,
1103 pub min_level: u32,
1104 pub worker_level: u32,
1105 pub can_afford: bool,
1106 pub level_ok: bool,
1107}
1108
1109#[derive(Debug, Clone)]
1111pub struct WorkerTeachPicker {
1112 pub worker_instance_id: String,
1113 pub worker_label: String,
1114 pub worker_level: u32,
1115 pub options: Vec<WorkerTeachOption>,
1116}
1117
1118#[derive(Debug, Clone)]
1120pub struct WorkerDismissConfirmation {
1121 pub worker_instance_id: String,
1122 pub worker_label: String,
1123}
1124
1125#[derive(Debug, Clone, Default)]
1128pub struct StickyWorkerStep {
1129 shown: String,
1130 pending: String,
1131 pending_since: Option<Instant>,
1132}
1133
1134impl StickyWorkerStep {
1135 fn from_label(label: String) -> Self {
1136 Self {
1137 shown: label.clone(),
1138 pending: label,
1139 pending_since: Some(Instant::now()),
1140 }
1141 }
1142
1143 fn observe(&mut self, label: &str, now: Instant) {
1144 let pending_since = self.pending_since.unwrap_or(now);
1145 if label == self.pending {
1146 if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD {
1147 self.shown = self.pending.clone();
1148 }
1149 return;
1150 }
1151 self.pending = label.to_string();
1152 self.pending_since = Some(now);
1153 if self.shown.is_empty() {
1155 self.shown = self.pending.clone();
1156 }
1157 }
1158}
1159
1160#[derive(Debug, Clone, Default)]
1163pub struct StickyWorkerError {
1164 message: String,
1165 last_seen: Option<Instant>,
1166}
1167
1168impl StickyWorkerError {
1169 fn observe(&mut self, err: Option<&str>, now: Instant) {
1170 if let Some(e) = err {
1171 if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
1172 self.message = e.to_string();
1173 self.last_seen = Some(now);
1174 }
1175 return;
1176 }
1177 if let Some(seen) = self.last_seen {
1178 if now.duration_since(seen) > WORKER_ERROR_HOLD {
1179 self.message.clear();
1180 self.last_seen = None;
1181 }
1182 }
1183 }
1184
1185 pub fn shown(&self, now: Instant) -> Option<&str> {
1186 if self.message.is_empty() {
1187 return None;
1188 }
1189 let seen = self.last_seen?;
1190 if now.duration_since(seen) > WORKER_ERROR_HOLD {
1191 return None;
1192 }
1193 Some(self.message.as_str())
1194 }
1195}
1196
1197pub fn worker_attention_line(state: &GameState) -> Option<String> {
1200 use flatland_protocol::WorkerStateView;
1201 let now = Instant::now();
1202 for w in &state.hired_workers {
1203 if matches!(w.state, WorkerStateView::Strike) {
1204 return Some(format!(
1205 "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
1206 w.label
1207 ));
1208 }
1209 let sticky = state
1210 .worker_error_display
1211 .get(&w.instance_id)
1212 .and_then(|s| s.shown(now))
1213 .filter(|e| !worker_error_is_hud_noise(e));
1214 let live = w
1215 .last_error
1216 .as_deref()
1217 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e));
1218 if let Some(err) = sticky.or(live) {
1219 if let Some(hint) = w
1220 .issue_hint
1221 .as_deref()
1222 .filter(|h| !h.is_empty())
1223 .or_else(|| worker_issue_fix_hint(err))
1224 {
1225 return Some(format!("Worker {}: {err} — {hint}", w.label));
1226 }
1227 return Some(format!("Worker {}: {err}", w.label));
1228 }
1229 if let Some(hint) = w.issue_hint.as_deref().filter(|h| !h.is_empty()) {
1231 return Some(format!("Worker {}: {hint}", w.label));
1232 }
1233 }
1234 None
1235}
1236
1237pub fn worker_issue_fix_hint(err: &str) -> Option<&'static str> {
1239 let e = err.to_ascii_lowercase();
1240 if e.contains("missing")
1241 || e.contains("container not found")
1242 || e.contains("lodging container not found")
1243 {
1244 return Some("edit route (e): replace the missing chest/bed");
1245 }
1246 if e.contains("stranded at interior") || e.contains("interior map coords") {
1247 return Some("recovered — continuing route");
1248 }
1249 if e.contains("stuck inside")
1250 || e.contains("sent outside")
1251 || e.contains("sent to door")
1252 || e.contains("left building")
1253 {
1254 return Some("auto-exit for outdoor work — restart after update if it still loops");
1255 }
1256 if e.contains("collapsed") || e.contains("need food") {
1257 return Some("stock lodging bed with food and drink");
1258 }
1259 if e.contains("overburdened") {
1260 return Some("add a deposit/sell stop, or empty their pack");
1261 }
1262 if e.contains("storage full") {
1263 return Some("empty or upgrade the destination chest, or reassign the deposit");
1264 }
1265 if e.contains("need a hoe") || e.contains("need a dibber") {
1266 return Some("give them the tool or withdraw it on the route");
1267 }
1268 None
1269}
1270
1271pub fn worker_error_is_transient(err: &str) -> bool {
1273 let e = err.to_ascii_lowercase();
1274 e.contains("continuing route") || e.starts_with("nothing to withdraw")
1275}
1276
1277pub fn worker_error_is_hud_noise(err: &str) -> bool {
1280 let e = err.to_ascii_lowercase();
1281 if e.contains("idling") && (e.contains("cannot reach") || e.contains("unreachable")) {
1283 return false;
1284 }
1285 e.contains("returned to lodging after path")
1286 || e.contains("path failure")
1287 || e.contains("no path to")
1288 || e.contains("pathfinding")
1289 || e.contains("repathing")
1291 || e.contains("nudged clear")
1292 || e.contains("path unreachable (plan failures 0, leg 0)")
1294 || e.contains("auto-recovery")
1296 || e.contains("stranded at interior map coords")
1297}
1298
1299#[derive(Debug, Clone)]
1301pub struct PendingWorkerJobAck {
1302 pub seq: u32,
1303 pub worker_instance_id: String,
1304 pub worker_label: String,
1305 pub idle: bool,
1306 pub stop_count: usize,
1307 pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1308 pub prev_mode: flatland_protocol::WorkerModeView,
1309 pub prev_step_label: String,
1310 pub prev_last_error: Option<String>,
1311}
1312
1313fn push_inventory_rows(
1314 rows: &mut Vec<InventoryRow>,
1315 depth: usize,
1316 stack: &flatland_protocol::ItemStack,
1317 from: &flatland_protocol::InventoryLocation,
1318 from_parent_instance_id: Option<uuid::Uuid>,
1319 section: InventorySection,
1320) {
1321 push_inventory_rows_filtered(
1322 rows,
1323 depth,
1324 stack,
1325 from,
1326 from_parent_instance_id,
1327 section,
1328 "",
1329 );
1330}
1331
1332fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1333 if filter.is_empty() {
1334 return true;
1335 }
1336 let f = filter.to_ascii_lowercase();
1337 let name = stack
1338 .display_name
1339 .as_deref()
1340 .unwrap_or("")
1341 .to_ascii_lowercase();
1342 let tid = stack.template_id.to_ascii_lowercase();
1343 name.contains(&f)
1344 || tid.contains(&f)
1345 || stack
1346 .contents
1347 .iter()
1348 .any(|c| stack_matches_filter(c, filter))
1349}
1350
1351fn push_inventory_rows_filtered(
1352 rows: &mut Vec<InventoryRow>,
1353 depth: usize,
1354 stack: &flatland_protocol::ItemStack,
1355 from: &flatland_protocol::InventoryLocation,
1356 from_parent_instance_id: Option<uuid::Uuid>,
1357 section: InventorySection,
1358 filter: &str,
1359) {
1360 if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1361 return;
1362 }
1363 let self_hit = filter.is_empty() || {
1364 let f = filter.to_ascii_lowercase();
1365 let name = stack
1366 .display_name
1367 .as_deref()
1368 .unwrap_or("")
1369 .to_ascii_lowercase();
1370 let tid = stack.template_id.to_ascii_lowercase();
1371 name.contains(&f) || tid.contains(&f)
1372 };
1373 rows.push(InventoryRow {
1374 depth,
1375 stack: stack.clone(),
1376 from: from.clone(),
1377 from_parent_instance_id,
1378 is_equip_shell: false,
1379 is_chest_shell: false,
1380 section,
1381 });
1382 for child in &stack.contents {
1383 if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1384 push_inventory_rows_filtered(
1385 rows,
1386 depth + 1,
1387 child,
1388 from,
1389 stack.item_instance_id,
1390 section,
1391 if self_hit { "" } else { filter },
1392 );
1393 }
1394 }
1395}
1396
1397#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1398pub enum ShopTab {
1399 #[default]
1400 Buy,
1401 Sell,
1402}
1403
1404#[derive(Debug, Clone)]
1405pub struct NpcChatState {
1406 pub npc_id: String,
1407 pub npc_label: String,
1408 pub lines: Vec<String>,
1409 pub input: String,
1410 pub pending: bool,
1411 pub talk_depth: flatland_protocol::NpcTalkDepth,
1412 pub trade_allowed: bool,
1413 pub banner: Option<String>,
1414 pub suggested_topics: Vec<String>,
1415}
1416
1417impl Default for NpcChatState {
1418 fn default() -> Self {
1419 Self {
1420 npc_id: String::new(),
1421 npc_label: String::new(),
1422 lines: Vec::new(),
1423 input: String::new(),
1424 pending: false,
1425 talk_depth: flatland_protocol::NpcTalkDepth::Full,
1426 trade_allowed: true,
1427 banner: None,
1428 suggested_topics: Vec::new(),
1429 }
1430 }
1431}
1432
1433pub fn npc_world_xy(state: &GameState, npc: &NpcView) -> (f32, f32) {
1435 npc.entity_id
1436 .and_then(|eid| state.entities.iter().find(|e| e.id == eid))
1437 .map(|e| (e.transform.position.x, e.transform.position.y))
1438 .unwrap_or((npc.x, npc.y))
1439}
1440
1441#[derive(Debug, Clone)]
1442pub struct GameState {
1443 pub session_id: SessionId,
1444 pub entity_id: EntityId,
1445 pub character_id: Option<uuid::Uuid>,
1447 pub tick: Tick,
1448 pub chunk_rev: u64,
1449 pub content_rev: u64,
1450 pub publish_rev: u64,
1451 pub entities: Vec<EntityState>,
1452 pub player: Option<EntityState>,
1453 pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1454 pub harvest_route_nodes: Vec<flatland_protocol::ResourceNodeView>,
1456 pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1457 pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1458 pub buildings: Vec<BuildingView>,
1459 pub doors: Vec<DoorView>,
1460 pub interior_map: Option<InteriorMapView>,
1461 pub npcs: Vec<NpcView>,
1462 pub blueprints: Vec<BlueprintView>,
1463 pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1465 pub world_x0: f32,
1467 pub world_y0: f32,
1468 pub world_width_m: f32,
1469 pub world_height_m: f32,
1470 pub terrain_zones: Vec<TerrainZoneView>,
1471 pub z_platforms: Vec<ZPlatformView>,
1472 pub z_transitions: Vec<ZTransitionView>,
1473 #[doc(hidden)]
1476 pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1477 pub world_clock: flatland_protocol::WorldClock,
1478 pub inventory: std::collections::HashMap<String, u32>,
1479 pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1480 pub item_catalog: std::collections::HashMap<String, ItemCatalogEntryView>,
1482 pub logs: VecDeque<String>,
1483 pub intents_sent: u64,
1484 pub ticks_received: u64,
1485 pub connected: bool,
1486 pub disconnect_reason: Option<String>,
1487 pub show_stats: bool,
1488 pub hud_log_hidden: bool,
1490 pub show_equip_menu: bool,
1491 pub equip_menu_index: usize,
1492 pub show_craft_menu: bool,
1493 pub craft_menu_index: usize,
1494 pub craft_batch_quantity: u32,
1496 pub craft_tab: CraftTab,
1498 pub craft_filter: String,
1500 pub craft_filter_focused: bool,
1501 pub craft_prefs: crate::craft_prefs::CraftCharacterPrefs,
1503 pub show_plot_build_menu: bool,
1505 pub plot_build_focus_wall: bool,
1507 pub plot_build_wall_index: usize,
1508 pub plot_build_roof_index: usize,
1509 pub show_shop_menu: bool,
1510 pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1511 pub bank_panel: Option<flatland_protocol::BankPanel>,
1512 pub bank_menu_index: usize,
1513 pub bank_ui_mode: BankUiMode,
1514 pub storage_panel: Option<flatland_protocol::StoragePanel>,
1515 pub market_panel: Option<flatland_protocol::MarketPanel>,
1516 pub market_menu_index: usize,
1518 pub market_filter: String,
1520 pub market_filter_focused: bool,
1521 pub market_category_filter: Option<&'static str>,
1523 pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1525 pub market_ui_mode: MarketUiMode,
1526 pub storage_menu_index: usize,
1527 pub storage_ui_mode: StorageUiMode,
1528 pub shop_tab: ShopTab,
1529 pub shop_menu_index: usize,
1530 pub shop_quantity: u32,
1531 pub shop_trade_log: VecDeque<String>,
1533 pub show_npc_verb_menu: bool,
1534 pub npc_verb_target: Option<String>,
1535 pub npc_verb_index: usize,
1536 pub npc_verb_notice: Option<String>,
1538 pub player_verbs: crate::social::PlayerVerbState,
1540 pub social_chat: crate::social::SocialChatState,
1541 pub trade_ui: crate::social::TradeUiState,
1542 pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1543 pub show_npc_chat: bool,
1544 pub npc_chat: Option<NpcChatState>,
1545 pub show_inventory_menu: bool,
1546 pub inventory_menu_index: usize,
1547 pub inventory_tab: InventoryTab,
1548 pub inventory_filter: String,
1549 pub inventory_filter_focused: bool,
1550 pub show_move_picker: bool,
1551 pub move_picker_index: usize,
1552 pub move_picker: Option<MovePicker>,
1553 pub show_grant_picker: bool,
1554 pub grant_picker_index: usize,
1555 pub grant_picker: Option<GrantTargetPicker>,
1556 pub show_destroy_picker: bool,
1557 pub destroy_confirm_pending: bool,
1558 pub destroy_picker: Option<DestroyPicker>,
1559 pub show_rename_prompt: bool,
1561 pub rename_plot_id: Option<uuid::Uuid>,
1563 pub highlighted_plot_id: Option<uuid::Uuid>,
1565 pub show_worker_rename: bool,
1567 pub rename_buffer: String,
1568 pub combat_target: Option<EntityId>,
1570 pub combat_target_label: Option<String>,
1571 pub ground_target: Option<(f32, f32, f32)>,
1574 pub combat_fx: Vec<flatland_protocol::CombatFx>,
1576 pub ground_hazards: Vec<flatland_protocol::GroundHazardView>,
1578 pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1580 pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1582 pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1584 pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1586 pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1588 pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1590 pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1592 pub claim_mode: Option<ClaimModeState>,
1594 pub relocate_mode: Option<RelocateModeState>,
1596 pub sell_plot_confirm: Option<uuid::Uuid>,
1598 pub sell_plot_armed_at: Option<Instant>,
1600 pub show_plant_menu: bool,
1602 pub plant_menu_index: usize,
1603 pub show_farm_access: bool,
1605 pub farm_access_name_draft: String,
1607 pub farm_access_discount_bps: u32,
1609 pub farm_access_index: usize,
1611 pub plant_quantity: u32,
1612 pub in_combat: bool,
1613 pub auto_attack: bool,
1614 pub combat_has_los: bool,
1615 pub attack_cd_ticks: u64,
1616 pub gcd_ticks: u64,
1617 pub weapon_ability_id: String,
1618 pub mainhand_template_id: Option<String>,
1619 pub mainhand_label: Option<String>,
1620 pub mainhand_instance_id: Option<uuid::Uuid>,
1621 pub offhand_template_id: Option<String>,
1622 pub offhand_label: Option<String>,
1623 pub offhand_instance_id: Option<uuid::Uuid>,
1624 pub mainhand_hand_slots: u8,
1625 pub defense: Option<flatland_protocol::DefenseHud>,
1626 pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1628 pub carry_mass: f32,
1629 pub carry_mass_max: f32,
1630 pub encumbrance: flatland_protocol::EncumbranceState,
1631 pub move_speed_mps: f32,
1633 pub move_speed_mult: f32,
1635 pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1637 pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1639 pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1641 pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1643 pub combat_target_detail: Option<CombatTargetHud>,
1644 pub cast_progress: Option<CastProgressHud>,
1645 pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1647 pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1649 pub ability_cooldowns: Vec<AbilityCooldownHud>,
1650 pub blocking_active: bool,
1651 pub max_target_slots: u8,
1652 pub combat_slots: Vec<CombatSlotHud>,
1653 pub rotation_presets: Vec<RotationPreset>,
1654 pub known_abilities: Vec<String>,
1656 pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1658 pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1660 pub hotbar: Vec<Option<String>>,
1662 pub max_abilities_per_rotation: u8,
1664 pub show_loadout_menu: bool,
1665 pub show_keychain_menu: bool,
1666 pub keychain_menu_index: usize,
1667 pub show_rotation_editor: bool,
1668 pub loadout_menu_index: usize,
1670 pub loadout_hotbar_slot: u8,
1672 pub loadout_ability_index: usize,
1674 pub loadout_focus_presets: bool,
1676 pub rotation_editor: RotationEditorState,
1677 pub harvest_in_progress: bool,
1679 pub harvest_started_at: Option<Instant>,
1681 pub pending_craft_ack: Option<(u32, String, u32)>,
1683 pub craft_channel_blueprint_id: Option<String>,
1686 pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1687 pub interactables: Vec<flatland_protocol::InteractableView>,
1688 pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1689 pub career: Option<flatland_protocol::PlayerCareerView>,
1690 pub character_sheet_tab: CharacterSheetTab,
1691 pub ledger_period: LedgerPeriod,
1692 pub show_quest_offer: bool,
1693 pub pending_quest_offers: Vec<flatland_protocol::QuestOffer>,
1694 pub quest_offer_index: usize,
1695 pub show_quest_menu: bool,
1696 pub quest_menu_index: usize,
1697 pub quest_withdraw_confirm: bool,
1698 pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1699 pub show_workers_menu: bool,
1700 pub workers_menu_index: usize,
1701 pub worker_dismiss_confirmation: Option<WorkerDismissConfirmation>,
1702 pub workers_menu_compact: bool,
1704 pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1707 pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1709 pub worker_health_ring_until: BTreeMap<EntityId, Instant>,
1711 pub pending_worker_hire_since: Option<Instant>,
1713 pub show_worker_give_picker: bool,
1715 pub worker_give_picker_index: usize,
1716 pub worker_give_picker: Option<WorkerGivePicker>,
1717 pub show_worker_give_target_picker: bool,
1719 pub worker_give_target_picker_index: usize,
1720 pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1721 pub show_worker_take_picker: bool,
1723 pub worker_take_picker_index: usize,
1724 pub worker_take_picker: Option<WorkerTakePicker>,
1725 pub show_worker_teach_picker: bool,
1727 pub worker_teach_picker_index: usize,
1728 pub worker_teach_picker: Option<WorkerTeachPicker>,
1729 pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1731 pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1733 pub attending_worker_instance_id: Option<String>,
1735 pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1737}
1738
1739#[derive(Debug, Clone, PartialEq, Eq)]
1740pub enum NpcVerbAction {
1741 Talk,
1742 Trade,
1743 Bank,
1744 Storage,
1745 Market,
1746 QuestTalk { quest_id: String },
1747 QuestGive { quest_id: String },
1748}
1749
1750#[derive(Debug, Clone, PartialEq, Eq)]
1751pub struct NpcVerbChoice {
1752 pub label: String,
1753 pub action: NpcVerbAction,
1754}
1755
1756impl std::fmt::Display for NpcVerbChoice {
1757 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1758 f.write_str(&self.label)
1759 }
1760}
1761
1762impl GameState {
1763 pub fn selected_quest_offer(&self) -> Option<&flatland_protocol::QuestOffer> {
1764 self.pending_quest_offers.get(self.quest_offer_index)
1765 }
1766
1767 pub fn push_quest_offer(&mut self, offer: flatland_protocol::QuestOffer) {
1768 if self
1769 .pending_quest_offers
1770 .iter()
1771 .any(|existing| existing.quest_id == offer.quest_id)
1772 {
1773 self.show_quest_offer = true;
1774 return;
1775 }
1776 self.pending_quest_offers.push(offer);
1777 self.show_quest_offer = true;
1778 }
1779
1780 pub fn remove_quest_offer(&mut self, quest_id: &str) {
1781 self.pending_quest_offers
1782 .retain(|offer| offer.quest_id != quest_id);
1783 if self.pending_quest_offers.is_empty() {
1784 self.show_quest_offer = false;
1785 self.quest_offer_index = 0;
1786 return;
1787 }
1788 self.quest_offer_index = self
1789 .quest_offer_index
1790 .min(self.pending_quest_offers.len() - 1);
1791 self.show_quest_offer = true;
1792 }
1793
1794 pub fn clear_quest_offers(&mut self) {
1795 self.pending_quest_offers.clear();
1796 self.quest_offer_index = 0;
1797 self.show_quest_offer = false;
1798 }
1799
1800 pub fn move_quest_offer_selection(&mut self, delta: i32) {
1801 let n = self.pending_quest_offers.len();
1802 if n == 0 {
1803 self.quest_offer_index = 0;
1804 return;
1805 }
1806 let idx = self.quest_offer_index as i32;
1807 self.quest_offer_index = (idx + delta).rem_euclid(n as i32) as usize;
1808 }
1809
1810 pub fn push_log(&mut self, line: impl Into<String>) {
1811 self.logs.push_back(line.into());
1812 while self.logs.len() > MAX_LOG_LINES {
1813 self.logs.pop_front();
1814 }
1815 }
1816
1817 pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1818 self.shop_trade_log.push_back(line.into());
1819 while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1820 self.shop_trade_log.pop_front();
1821 }
1822 }
1823
1824 pub fn clear_shop_trade_log(&mut self) {
1825 self.shop_trade_log.clear();
1826 }
1827
1828 fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1829 if !self.show_shop_menu {
1830 return;
1831 }
1832 let msg = notice.message.trim();
1833 if msg.is_empty() {
1834 return;
1835 }
1836 if notice.coins_delta != 0
1837 || msg.starts_with("Bought ")
1838 || msg.starts_with("Sold ")
1839 || msg.contains("taught you how to craft")
1840 || msg.starts_with("need ")
1841 {
1842 self.push_shop_trade_log(msg);
1843 }
1844 }
1845
1846 pub fn is_alive(&self) -> bool {
1847 self.player
1848 .as_ref()
1849 .and_then(|p| p.vitals)
1850 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1851 .unwrap_or(true)
1852 }
1853
1854 pub fn push_audio(&mut self, cue: crate::social::AudioCue) {
1855 self.social_chat.push_cue(cue);
1856 }
1857
1858 fn sync_gameplay_audio(&mut self) {
1860 use crate::social::AudioCue;
1861 use flatland_protocol::PrimaryAttributes;
1862
1863 let alive = self.is_alive();
1864 let casting = self.cast_progress.is_some();
1865 let telegraph = self.focus_attack_telegraph_active();
1866 let in_aoe = self.player_inside_spatial_telegraph();
1867 let quest_sig = self.quest_audio_signature();
1868 let entity_id = self.entity_id;
1869 let char_level = self
1870 .player
1871 .as_ref()
1872 .and_then(|p| p.attributes)
1873 .map(|a| {
1874 PrimaryAttributes::display(a.strength)
1875 .saturating_add(PrimaryAttributes::display(a.dexterity))
1876 .saturating_add(PrimaryAttributes::display(a.intelligence))
1877 .saturating_add(PrimaryAttributes::display(a.stamina))
1878 .saturating_add(PrimaryAttributes::display(a.vitality))
1879 .saturating_add(PrimaryAttributes::display(a.wisdom))
1880 .saturating_add(PrimaryAttributes::display(a.charisma))
1881 })
1882 .unwrap_or(0);
1883
1884 let fx_ids: Vec<u64> = self.combat_fx.iter().map(|fx| fx.id).collect();
1885 let mut hit_cues = Vec::new();
1886 {
1887 let seen = &self.social_chat.audio_seen_fx_ids;
1888 for fx in &self.combat_fx {
1889 if seen.contains(&fx.id) {
1890 continue;
1891 }
1892 let Some(hit) = fx.hits.iter().find(|h| h.entity_id == entity_id) else {
1893 continue;
1894 };
1895 if hit.outcome == CombatFxHitOutcome::Blocked {
1896 hit_cues.push(AudioCue::CombatBlock);
1897 } else {
1898 let heavy = matches!(
1899 fx.kind,
1900 CombatFxKind::Sphere | CombatFxKind::Cone | CombatFxKind::Beam
1901 );
1902 hit_cues.push(if heavy {
1903 AudioCue::CombatHitHeavy
1904 } else {
1905 AudioCue::CombatHitLight
1906 });
1907 }
1908 }
1909 }
1910
1911 let audio = &mut self.social_chat;
1912 if !audio.audio_bootstrapped {
1913 audio.audio_was_alive = alive;
1914 audio.audio_was_casting = casting;
1915 audio.audio_had_target_telegraph = telegraph;
1916 audio.audio_was_in_aoe = in_aoe;
1917 audio.audio_quest_sig = quest_sig;
1918 audio.audio_char_level = char_level;
1919 audio.audio_seen_fx_ids = fx_ids;
1920 audio.audio_bootstrapped = true;
1921 return;
1922 }
1923
1924 if telegraph && !audio.audio_had_target_telegraph {
1925 audio.push_cue(AudioCue::CombatTelegraphStart);
1926 } else if !telegraph && audio.audio_had_target_telegraph {
1927 audio.push_cue(AudioCue::CombatTelegraphImpact);
1928 }
1929 audio.audio_had_target_telegraph = telegraph;
1930
1931 if in_aoe && !audio.audio_was_in_aoe {
1932 audio.push_cue(AudioCue::CombatAoeWarn);
1933 }
1934 audio.audio_was_in_aoe = in_aoe;
1935
1936 if casting && !audio.audio_was_casting {
1937 audio.push_cue(AudioCue::AbilityCastSelf);
1938 }
1939 audio.audio_was_casting = casting;
1940
1941 if !alive && audio.audio_was_alive {
1942 audio.push_cue(AudioCue::PlayerDeath);
1943 }
1944 audio.audio_was_alive = alive;
1945
1946 if quest_sig != audio.audio_quest_sig && audio.audio_quest_sig != 0 {
1947 audio.push_cue(AudioCue::QuestUpdate);
1948 }
1949 audio.audio_quest_sig = quest_sig;
1950
1951 if char_level > audio.audio_char_level && audio.audio_char_level > 0 {
1952 audio.push_cue(AudioCue::LevelUp);
1953 }
1954 audio.audio_char_level = char_level;
1955
1956 for cue in hit_cues {
1957 audio.push_cue(cue);
1958 }
1959 audio.audio_seen_fx_ids = fx_ids;
1960 }
1961
1962 fn focus_attack_telegraph_active(&self) -> bool {
1963 let Some(tid) = self.combat_target else {
1964 return false;
1965 };
1966 self.entities
1967 .iter()
1968 .find(|e| e.id == tid)
1969 .map(|e| {
1970 e.combat_cues.iter().any(|c| {
1971 matches!(c.kind, CombatCueKind::AttackTelegraph) && c.until_tick > self.tick
1972 })
1973 })
1974 .unwrap_or(false)
1975 }
1976
1977 fn player_inside_spatial_telegraph(&self) -> bool {
1978 let (px, py) = self.player_position();
1979 for e in &self.entities {
1980 for cue in &e.combat_cues {
1981 if !matches!(cue.kind, CombatCueKind::AttackTelegraph)
1982 || cue.until_tick <= self.tick
1983 {
1984 continue;
1985 }
1986 let Some(kind) = cue.telegraph_kind else {
1987 continue;
1988 };
1989 let (ox, oy) = match (cue.origin_x, cue.origin_y) {
1990 (Some(x), Some(y)) => (x, y),
1991 _ => continue,
1992 };
1993 match kind {
1994 CombatFxKind::Sphere => {
1995 let r = cue.radius_m.unwrap_or(1.0);
1996 let dx = px - ox;
1997 let dy = py - oy;
1998 if dx * dx + dy * dy <= r * r {
1999 return true;
2000 }
2001 }
2002 CombatFxKind::Cone | CombatFxKind::MeleeArc => {
2003 let reach = cue.reach_m.unwrap_or(2.0);
2004 let yaw = cue.yaw.unwrap_or(0.0);
2005 let arc = cue.arc_deg.unwrap_or(90.0).to_radians();
2006 let dx = px - ox;
2007 let dy = py - oy;
2008 let dist = (dx * dx + dy * dy).sqrt();
2009 if dist > reach || dist < 0.05 {
2010 continue;
2011 }
2012 let ang = dx.atan2(dy);
2013 let mut delta = ang - yaw;
2014 while delta > std::f32::consts::PI {
2015 delta -= std::f32::consts::TAU;
2016 }
2017 while delta < -std::f32::consts::PI {
2018 delta += std::f32::consts::TAU;
2019 }
2020 if delta.abs() <= arc * 0.5 {
2021 return true;
2022 }
2023 }
2024 _ => {}
2025 }
2026 }
2027 }
2028 false
2029 }
2030
2031 fn quest_audio_signature(&self) -> u64 {
2032 use std::collections::hash_map::DefaultHasher;
2033 use std::hash::{Hash, Hasher};
2034 let mut h = DefaultHasher::new();
2035 for q in &self.quest_log {
2036 q.quest_id.hash(&mut h);
2037 format!("{:?}", q.status).hash(&mut h);
2038 q.current_step_id.hash(&mut h);
2039 for o in &q.objectives {
2040 o.done.hash(&mut h);
2041 o.current.hash(&mut h);
2042 }
2043 }
2044 h.finish()
2045 }
2046
2047 pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
2049 let Some(ref id) = self.npc_verb_target else {
2050 return vec![];
2051 };
2052 let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
2053 return self.with_quest_verbs(id, vec![Self::talk_choice()]);
2054 };
2055 let role = npc.role.as_str();
2056 let rest = if Self::npc_role_is_bank(role) {
2057 vec![
2058 NpcVerbChoice {
2059 label: "Bank".into(),
2060 action: NpcVerbAction::Bank,
2061 },
2062 Self::talk_choice(),
2063 ]
2064 } else if Self::npc_role_is_storage(role) {
2065 vec![
2066 NpcVerbChoice {
2067 label: "Storage".into(),
2068 action: NpcVerbAction::Storage,
2069 },
2070 Self::talk_choice(),
2071 ]
2072 } else if Self::npc_role_is_market(role) {
2073 vec![
2074 NpcVerbChoice {
2075 label: "Market".into(),
2076 action: NpcVerbAction::Market,
2077 },
2078 Self::talk_choice(),
2079 ]
2080 } else if npc.can_trade || Self::npc_role_can_trade(role) {
2081 vec![
2082 Self::talk_choice(),
2083 NpcVerbChoice {
2084 label: "Trade".into(),
2085 action: NpcVerbAction::Trade,
2086 },
2087 ]
2088 } else {
2089 vec![Self::talk_choice()]
2090 };
2091 self.with_quest_verbs(id, rest)
2092 }
2093
2094 fn talk_choice() -> NpcVerbChoice {
2095 NpcVerbChoice {
2096 label: "Talk".into(),
2097 action: NpcVerbAction::Talk,
2098 }
2099 }
2100
2101 fn with_quest_verbs(&self, npc_id: &str, rest: Vec<NpcVerbChoice>) -> Vec<NpcVerbChoice> {
2102 let mut opts = self.quest_verb_choices(npc_id);
2103 opts.extend(rest);
2104 opts
2105 }
2106
2107 fn quest_verb_choices(&self, npc_id: &str) -> Vec<NpcVerbChoice> {
2108 if let Some(npc) = self.npcs.iter().find(|n| n.id == npc_id) {
2109 if !npc.quest_verbs.is_empty() {
2110 return npc
2111 .quest_verbs
2112 .iter()
2113 .map(|v| {
2114 let action = if v.kind == flatland_protocol::NpcQuestVerb::KIND_GIVE {
2115 NpcVerbAction::QuestGive {
2116 quest_id: v.quest_id.clone(),
2117 }
2118 } else {
2119 NpcVerbAction::QuestTalk {
2120 quest_id: v.quest_id.clone(),
2121 }
2122 };
2123 NpcVerbChoice {
2124 label: v.label.clone(),
2125 action,
2126 }
2127 })
2128 .collect();
2129 }
2130 }
2131 let catalog_ref = self.npc_quest_catalog_ref(npc_id);
2132 let mut opts = Vec::new();
2133 for q in &self.quest_log {
2134 if q.status != flatland_protocol::QuestStatusView::Active {
2135 continue;
2136 }
2137 let title = if q.title.trim().is_empty() {
2138 "Quest".to_string()
2139 } else {
2140 q.title.clone()
2141 };
2142 for o in &q.objectives {
2143 if o.done || o.npc_ref.as_deref() != Some(catalog_ref.as_str()) {
2144 continue;
2145 }
2146 if o.kind == "give_item" {
2147 opts.push(NpcVerbChoice {
2148 label: format!("Turn in: {title}"),
2149 action: NpcVerbAction::QuestGive {
2150 quest_id: q.quest_id.clone(),
2151 },
2152 });
2153 } else if o.kind == "talk_npc" {
2154 opts.push(NpcVerbChoice {
2155 label: title.clone(),
2156 action: NpcVerbAction::QuestTalk {
2157 quest_id: q.quest_id.clone(),
2158 },
2159 });
2160 }
2161 }
2162 }
2163 opts
2164 }
2165
2166 fn npc_quest_catalog_ref(&self, npc_id: &str) -> String {
2167 self.npcs
2168 .iter()
2169 .find(|n| n.id == npc_id)
2170 .and_then(|n| n.paperdoll_ref.clone())
2171 .unwrap_or_else(|| npc_id.to_string())
2172 }
2173
2174 fn count_inventory_template(&self, template: &str) -> u32 {
2175 self.inventory_stacks
2176 .iter()
2177 .filter(|s| s.template_id == template)
2178 .map(|s| s.quantity)
2179 .sum()
2180 }
2181
2182 fn npc_role_can_trade(role: &str) -> bool {
2183 matches!(role, "broker" | "cook" | "farmer" | "merchant")
2184 }
2185
2186 fn npc_role_is_bank(role: &str) -> bool {
2187 role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
2188 }
2189
2190 fn npc_role_is_storage(role: &str) -> bool {
2191 role.eq_ignore_ascii_case("storage_manager")
2192 }
2193
2194 fn npc_role_is_market(role: &str) -> bool {
2195 role.eq_ignore_ascii_case("market_clerk")
2196 }
2197
2198 pub fn bank_menu_options(&self) -> Vec<&'static str> {
2199 vec![
2200 "Deposit…",
2201 "Withdraw…",
2202 "Deposit all",
2203 "Withdraw all",
2204 "Transfer…",
2205 ]
2206 }
2207
2208 pub fn storage_menu_options(&self) -> Vec<String> {
2209 let mut opts = vec!["Store…".into(), "Take…".into()];
2210 if let Some(panel) = &self.storage_panel {
2211 for dest in &panel.ship_destinations {
2212 opts.push(format!(
2213 "Ship → {} ({} cp / {} ticks)",
2214 dest.label, dest.fee_copper, dest.travel_ticks
2215 ));
2216 }
2217 }
2218 opts
2219 }
2220
2221 pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
2225 let equipped = self.hand_equipped_instance_ids();
2226 self.person_rows()
2227 .into_iter()
2228 .filter(|r| r.depth == 0)
2229 .filter_map(|r| {
2230 let id = r.stack.item_instance_id?;
2231 if equipped.contains(&id) {
2232 return None;
2233 }
2234 Some(StoragePickOption {
2235 item_instance_id: id,
2236 template_id: r.stack.template_id.clone(),
2237 label: storage_stack_label(&r.stack),
2238 quantity: r.stack.quantity,
2239 category: r.stack.category.clone().unwrap_or_default(),
2240 })
2241 })
2242 .collect()
2243 }
2244
2245 pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
2247 let mut ids = std::collections::HashSet::new();
2248 if let Some(id) = self.mainhand_instance_id {
2249 ids.insert(id);
2250 } else if let Some(tid) = &self.mainhand_template_id {
2251 if let Some(id) = self
2252 .inventory_stacks
2253 .iter()
2254 .find(|s| &s.template_id == tid)
2255 .and_then(|s| s.item_instance_id)
2256 {
2257 ids.insert(id);
2258 }
2259 }
2260 if let Some(id) = self.offhand_instance_id {
2261 ids.insert(id);
2262 } else if let Some(tid) = &self.offhand_template_id {
2263 if let Some(id) = self
2264 .inventory_stacks
2265 .iter()
2266 .find(|s| {
2267 &s.template_id == tid
2268 && s.item_instance_id.is_some_and(|iid| !ids.contains(&iid))
2269 })
2270 .and_then(|s| s.item_instance_id)
2271 {
2272 ids.insert(id);
2273 }
2274 }
2275 ids
2276 }
2277
2278 pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
2280 let Some(panel) = &self.storage_panel else {
2281 return Vec::new();
2282 };
2283 panel
2284 .contents
2285 .iter()
2286 .filter_map(|s| {
2287 let id = s.item_instance_id?;
2288 Some(StoragePickOption {
2289 item_instance_id: id,
2290 template_id: s.template_id.clone(),
2291 label: storage_stack_label(s),
2292 quantity: s.quantity,
2293 category: s.category.clone().unwrap_or_default(),
2294 })
2295 })
2296 .collect()
2297 }
2298
2299 pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
2301 let mut opts = Vec::new();
2302 if !self
2303 .market_list_item_options(&MarketListSourceKind::Person)
2304 .is_empty()
2305 {
2306 opts.push((MarketListSourceKind::Person, "On person".into()));
2307 }
2308 if let Some(panel) = &self.market_panel {
2309 for vault in &panel.list_vaults {
2310 let source = MarketListSourceKind::TownStorage {
2311 building_id: vault.building_id.clone(),
2312 };
2313 if self.market_list_item_options(&source).is_empty() {
2314 continue;
2315 }
2316 let label = if vault.building_label.is_empty() {
2317 format!("Town storage ({})", vault.building_id)
2318 } else {
2319 format!("Town storage — {}", vault.building_label)
2320 };
2321 opts.push((source, label));
2322 }
2323 }
2324 opts
2325 }
2326
2327 pub fn market_list_item_options(
2329 &self,
2330 source: &MarketListSourceKind,
2331 ) -> Vec<StoragePickOption> {
2332 let filter = self.market_filter.as_str();
2333 let cat_filter = self.market_category_filter;
2334 let mut opts: Vec<StoragePickOption> = match source {
2335 MarketListSourceKind::Person => {
2336 let equipped = self.hand_equipped_instance_ids();
2337 self.person_rows()
2338 .into_iter()
2339 .filter(|r| r.depth == 0)
2340 .filter(|r| self.stack_is_market_listable(&r.stack))
2341 .filter_map(|r| {
2342 let id = r.stack.item_instance_id?;
2343 if equipped.contains(&id) {
2344 return None;
2345 }
2346 Some(StoragePickOption {
2347 item_instance_id: id,
2348 template_id: r.stack.template_id.clone(),
2349 label: storage_stack_label(&r.stack),
2350 quantity: r.stack.quantity,
2351 category: r
2352 .stack
2353 .category
2354 .clone()
2355 .or_else(|| {
2356 self.inventory_item_category(&r.stack.template_id)
2357 .map(str::to_string)
2358 })
2359 .unwrap_or_default(),
2360 })
2361 })
2362 .collect()
2363 }
2364 MarketListSourceKind::TownStorage { building_id } => {
2365 let Some(panel) = &self.market_panel else {
2366 return Vec::new();
2367 };
2368 let Some(vault) = panel
2369 .list_vaults
2370 .iter()
2371 .find(|v| &v.building_id == building_id)
2372 else {
2373 return Vec::new();
2374 };
2375 vault
2376 .contents
2377 .iter()
2378 .filter(|s| self.stack_is_market_listable(s))
2379 .filter_map(|s| {
2380 let id = s.item_instance_id?;
2381 Some(StoragePickOption {
2382 item_instance_id: id,
2383 template_id: s.template_id.clone(),
2384 label: storage_stack_label(s),
2385 quantity: s.quantity,
2386 category: s
2387 .category
2388 .clone()
2389 .or_else(|| {
2390 self.inventory_item_category(&s.template_id)
2391 .map(str::to_string)
2392 })
2393 .unwrap_or_default(),
2394 })
2395 })
2396 .collect()
2397 }
2398 };
2399 opts.retain(|o| {
2400 if !list_label_matches(&o.label, filter) {
2401 return false;
2402 }
2403 if let Some(group) = cat_filter {
2404 inventory_category_group(&o.category).0 == group
2405 } else {
2406 true
2407 }
2408 });
2409 opts
2410 }
2411
2412 pub fn item_base_value_copper_hint(&self, template_id: &str) -> Option<u32> {
2414 if let Some(hint) = self.inventory_hints.get(template_id) {
2415 if let Some(v) = hint.base_value_copper.filter(|v| *v > 0) {
2416 return Some(v);
2417 }
2418 }
2419 if let Some(v) = self
2420 .inventory_stacks
2421 .iter()
2422 .find(|s| s.template_id == template_id)
2423 .and_then(|s| s.base_value_copper.filter(|v| *v > 0))
2424 {
2425 return Some(v);
2426 }
2427 self.market_panel.as_ref().and_then(|panel| {
2428 panel.list_vaults.iter().find_map(|vault| {
2429 vault.contents.iter().find_map(|stack| {
2430 (stack.template_id == template_id)
2431 .then(|| stack.base_value_copper.filter(|v| *v > 0))
2432 .flatten()
2433 })
2434 })
2435 })
2436 }
2437
2438 pub fn npc_market_dump_unit_estimate(&self, template_id: &str) -> Option<u32> {
2440 let base = self.item_base_value_copper_hint(template_id)?;
2441 npc_market_dump_unit_estimate_copper(base)
2442 }
2443
2444 fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
2445 if crate::currency::is_currency(&stack.template_id) {
2446 return false;
2447 }
2448 if let Some(flag) = stack.listable {
2449 return flag;
2450 }
2451 if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
2452 return hint.listable;
2453 }
2454 let cat = stack
2455 .category
2456 .as_deref()
2457 .or_else(|| self.inventory_item_category(&stack.template_id))
2458 .unwrap_or("");
2459 category_default_listable(cat)
2460 }
2461
2462 pub fn market_available_category_groups(&self) -> Vec<&'static str> {
2464 let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
2465 match &self.market_ui_mode {
2466 MarketUiMode::ListPick { source, .. } => {
2467 let raw: Vec<_> = match source {
2468 MarketListSourceKind::Person => self
2469 .person_rows()
2470 .into_iter()
2471 .filter(|r| r.depth == 0)
2472 .filter(|r| self.stack_is_market_listable(&r.stack))
2473 .filter(|r| {
2474 list_label_matches(&storage_stack_label(&r.stack), &self.market_filter)
2475 })
2476 .map(|r| {
2477 r.stack
2478 .category
2479 .clone()
2480 .or_else(|| {
2481 self.inventory_item_category(&r.stack.template_id)
2482 .map(str::to_string)
2483 })
2484 .unwrap_or_default()
2485 })
2486 .collect(),
2487 MarketListSourceKind::TownStorage { building_id } => self
2488 .market_panel
2489 .as_ref()
2490 .and_then(|p| p.list_vaults.iter().find(|v| &v.building_id == building_id))
2491 .map(|vault| {
2492 vault
2493 .contents
2494 .iter()
2495 .filter(|s| self.stack_is_market_listable(s))
2496 .filter(|s| {
2497 list_label_matches(&storage_stack_label(s), &self.market_filter)
2498 })
2499 .map(|s| {
2500 s.category
2501 .clone()
2502 .or_else(|| {
2503 self.inventory_item_category(&s.template_id)
2504 .map(str::to_string)
2505 })
2506 .unwrap_or_default()
2507 })
2508 .collect::<Vec<_>>()
2509 })
2510 .unwrap_or_default(),
2511 };
2512 for category in raw {
2513 let (label, ord) = inventory_category_group(&category);
2514 seen.insert(ord, label);
2515 }
2516 }
2517 _ => {
2518 if let Some(panel) = &self.market_panel {
2519 for listing in &panel.listings {
2520 if !list_label_matches(&listing.display_name, &self.market_filter)
2521 && !list_label_matches(&listing.seller_label, &self.market_filter)
2522 {
2523 continue;
2524 }
2525 let (label, ord) = inventory_category_group(&listing.category);
2526 seen.insert(ord, label);
2527 }
2528 }
2529 }
2530 }
2531 seen.into_values().collect()
2532 }
2533
2534 pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
2536 let Some(panel) = &self.market_panel else {
2537 return Vec::new();
2538 };
2539 let filter = self.market_filter.as_str();
2540 let cat_filter = self.market_category_filter;
2541 panel
2542 .listings
2543 .iter()
2544 .enumerate()
2545 .filter(|(_, listing)| {
2546 if !list_label_matches(&listing.display_name, filter)
2547 && !list_label_matches(&listing.seller_label, filter)
2548 && !list_label_matches(&listing.template_id, filter)
2549 {
2550 return false;
2551 }
2552 if let Some(group) = cat_filter {
2553 inventory_category_group(&listing.category).0 == group
2554 } else {
2555 true
2556 }
2557 })
2558 .map(|(i, _)| i)
2559 .collect()
2560 }
2561
2562 pub fn clear_harvest_state(&mut self) {
2563 self.harvest_in_progress = false;
2564 self.harvest_started_at = None;
2565 }
2566
2567 fn harvest_state_stale(&self) -> bool {
2568 match self.harvest_started_at {
2569 Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
2570 None => self.harvest_in_progress,
2571 }
2572 }
2573
2574 pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
2575 self.player.as_ref().and_then(|p| p.vitals)
2576 }
2577
2578 pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
2579 let materials_ok = blueprint.inputs.iter().all(|input| {
2580 self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
2581 });
2582 let tools_ok = blueprint
2583 .required_tools
2584 .iter()
2585 .all(|tool| self.player_has_craft_tool(&tool.item));
2586 let station_ok = match blueprint.station.as_deref() {
2587 None | Some("hand") => true,
2588 Some(tag) => self.player_at_station_tag(tag),
2589 };
2590 materials_ok && tools_ok && station_ok && self.craft_has_vessel_room_for_output(blueprint)
2591 }
2592
2593 pub fn player_has_craft_tool(&self, tool_template: &str) -> bool {
2595 if self.inventory.get(tool_template).copied().unwrap_or(0) >= 1 {
2596 return true;
2597 }
2598 let Some(player) = self.player.as_ref() else {
2599 return false;
2600 };
2601 let px = player.transform.position.x;
2602 let py = player.transform.position.y;
2603 const RANGE: f32 = 3.0;
2605 self.placed_containers.iter().any(|c| {
2606 if c.template_id != tool_template {
2607 return false;
2608 }
2609 if !self.placed_container_in_current_space(c) {
2610 return false;
2611 }
2612 let dx = c.x - px;
2613 let dy = c.y - py;
2614 dx * dx + dy * dy <= RANGE * RANGE
2615 })
2616 }
2617
2618 fn craft_output_needs_vessel(&self, blueprint: &BlueprintView) -> bool {
2620 matches!(
2621 self.inventory_item_category(&blueprint.output),
2622 Some("bulk") | Some("liquid")
2623 ) || matches!(
2624 blueprint.output.as_str(),
2625 "dirt" | "mud" | "sand" | "water" | "milk"
2626 )
2627 }
2628
2629 fn craft_output_category(&self, blueprint: &BlueprintView) -> Option<&str> {
2630 self.inventory_item_category(&blueprint.output).or_else(|| {
2631 match blueprint.output.as_str() {
2632 "dirt" | "mud" | "sand" => Some("bulk"),
2633 "water" | "milk" => Some("liquid"),
2634 _ => None,
2635 }
2636 })
2637 }
2638
2639 fn craft_has_vessel_room_for_output(&self, blueprint: &BlueprintView) -> bool {
2640 if !self.craft_output_needs_vessel(blueprint) {
2641 return true;
2642 }
2643 let need = blueprint.output_qty.max(1);
2644 self.vessel_room_after_craft_inputs(blueprint) >= need
2645 }
2646
2647 fn vessel_room_after_craft_inputs(&self, blueprint: &BlueprintView) -> u32 {
2649 let mut stacks = self.inventory_stacks.clone();
2650 for worn in self.worn.values() {
2651 stacks.push(worn.clone());
2652 }
2653 for input in &blueprint.inputs {
2654 let mut left = input.quantity;
2655 drain_payload_from_stacks(&mut stacks, &input.template_id, &mut left);
2656 if left > 0 {
2657 return 0;
2658 }
2659 }
2660 vessel_room_for_payload_in_stacks(
2661 &stacks,
2662 &blueprint.output,
2663 self.craft_output_category(blueprint),
2664 )
2665 }
2666
2667 pub fn craft_vessel_status(&self, blueprint: &BlueprintView) -> CraftVesselStatus {
2669 let output_label = self.blueprint_output_label(blueprint);
2670 let needs_vessel = self.craft_output_needs_vessel(blueprint);
2671 let need_units = if needs_vessel {
2672 blueprint.output_qty.max(1)
2673 } else {
2674 0
2675 };
2676 let free_after_inputs = if needs_vessel {
2677 self.vessel_room_after_craft_inputs(blueprint)
2678 } else {
2679 0
2680 };
2681 let payload_cat = self.craft_output_category(blueprint);
2682 let mut vessels = Vec::new();
2683 Self::collect_craft_vessel_lines(
2684 &self.inventory_stacks,
2685 "pack",
2686 &blueprint.output,
2687 payload_cat,
2688 &mut vessels,
2689 );
2690 for worn in self.worn.values() {
2691 Self::collect_craft_vessel_lines(
2692 std::slice::from_ref(worn),
2693 "worn",
2694 &blueprint.output,
2695 payload_cat,
2696 &mut vessels,
2697 );
2698 }
2699 CraftVesselStatus {
2700 needs_vessel,
2701 output_label,
2702 need_units,
2703 free_after_inputs,
2704 ok: !needs_vessel || free_after_inputs >= need_units,
2705 vessels,
2706 }
2707 }
2708
2709 fn collect_craft_vessel_lines(
2710 stacks: &[flatland_protocol::ItemStack],
2711 location: &'static str,
2712 payload_id: &str,
2713 payload_category: Option<&str>,
2714 out: &mut Vec<CraftVesselLine>,
2715 ) {
2716 for stack in stacks {
2717 if is_serving_vessel_stack(stack) {
2718 let cap = serving_capacity_of(stack);
2719 let used = payload_units_in_vessel(stack);
2720 let free = vessel_free_room_for_payload(stack, payload_id, payload_category);
2721 let holds = stack
2722 .props
2723 .get("serving_holds")
2724 .cloned()
2725 .unwrap_or_else(|| {
2726 if stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
2727 && stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
2728 {
2729 "liquid,bulk".into()
2730 } else if stack.props.get("bulk_vessel").is_some_and(|v| v == "1") {
2731 "bulk".into()
2732 } else if stack.props.get("liquid_vessel").is_some_and(|v| v == "1") {
2733 "liquid".into()
2734 } else {
2735 "?".into()
2736 }
2737 });
2738 let label = stack
2739 .display_name
2740 .clone()
2741 .unwrap_or_else(|| stack.template_id.clone());
2742 out.push(CraftVesselLine {
2743 label,
2744 holds,
2745 capacity: cap,
2746 used,
2747 free,
2748 quantity: stack.quantity.max(1),
2749 accepts_output: free > 0,
2750 location,
2751 });
2752 }
2753 Self::collect_craft_vessel_lines(
2754 &stack.contents,
2755 location,
2756 payload_id,
2757 payload_category,
2758 out,
2759 );
2760 }
2761 }
2762
2763 fn craft_prefs_key(&self) -> String {
2764 if let Some(cid) = self.character_id {
2765 cid.to_string()
2766 } else if self.entity_id != 0 {
2767 format!("entity:{}", self.entity_id)
2768 } else {
2769 String::new()
2770 }
2771 }
2772
2773 pub fn reload_craft_prefs(&mut self) {
2774 let key = self.craft_prefs_key();
2775 self.craft_prefs = crate::craft_prefs::load_for_character(&key);
2776 }
2777
2778 fn persist_craft_prefs(&self) {
2779 crate::craft_prefs::save_for_character(&self.craft_prefs_key(), &self.craft_prefs);
2780 }
2781
2782 pub fn craft_known_tiers(&self) -> Vec<u32> {
2784 let mut tiers: Vec<u32> = self
2785 .blueprints
2786 .iter()
2787 .map(|bp| bp.craft_tier.max(1))
2788 .collect::<std::collections::BTreeSet<_>>()
2789 .into_iter()
2790 .collect();
2791 tiers.sort_unstable();
2792 tiers
2793 }
2794
2795 pub fn craft_tab_strip(&self) -> Vec<CraftTab> {
2797 let mut tabs = vec![CraftTab::Ready, CraftTab::Favorites, CraftTab::Recent];
2798 for t in self.craft_known_tiers() {
2799 tabs.push(CraftTab::Tier(t));
2800 }
2801 tabs
2802 }
2803
2804 pub fn craft_set_tab(&mut self, tab: CraftTab) {
2805 self.craft_tab = tab;
2806 self.craft_menu_index = 0;
2807 self.clamp_craft_menu_index();
2808 self.clamp_craft_batch_quantity();
2809 }
2810
2811 pub fn craft_cycle_tab(&mut self, delta: i32) {
2812 let tabs = self.craft_tab_strip();
2813 if tabs.is_empty() {
2814 return;
2815 }
2816 let cur = tabs.iter().position(|t| *t == self.craft_tab).unwrap_or(0) as i32;
2817 let next = (cur + delta).rem_euclid(tabs.len() as i32) as usize;
2818 self.craft_set_tab(tabs[next]);
2819 }
2820
2821 pub fn craft_matches_search(&self, bp: &BlueprintView) -> bool {
2822 let f = self.craft_filter.trim();
2823 if f.is_empty() {
2824 return true;
2825 }
2826 if list_label_matches(&bp.label, f)
2827 || list_label_matches(&bp.output, f)
2828 || list_label_matches(&bp.output_display_name, f)
2829 || bp
2830 .category
2831 .as_deref()
2832 .is_some_and(|c| list_label_matches(c, f))
2833 || bp
2834 .station
2835 .as_deref()
2836 .is_some_and(|s| list_label_matches(s, f))
2837 {
2838 return true;
2839 }
2840 bp.inputs.iter().any(|i| {
2841 list_label_matches(&i.template_id, f) || list_label_matches(&i.display_name, f)
2842 }) || bp
2843 .required_tools
2844 .iter()
2845 .any(|t| list_label_matches(&t.item, f) || list_label_matches(&t.display_name, f))
2846 }
2847
2848 pub fn craft_blueprint_in_channel(&self, blueprint_id: &str) -> bool {
2850 self.craft_channel_blueprint_id.as_deref() == Some(blueprint_id)
2851 && self.active_craft_channel().is_some()
2852 }
2853
2854 pub fn craft_filtered_indices(&self) -> Vec<usize> {
2856 let mut idxs: Vec<usize> = (0..self.blueprints.len())
2857 .filter(|&i| {
2858 let bp = &self.blueprints[i];
2859 if !self.craft_matches_search(bp) {
2860 return false;
2861 }
2862 match self.craft_tab {
2863 CraftTab::Ready => {
2864 self.can_craft_blueprint(bp) || self.craft_blueprint_in_channel(&bp.id)
2865 }
2866 CraftTab::Favorites => self.craft_prefs.is_favorite(&bp.id),
2867 CraftTab::Recent => self.craft_prefs.recent.iter().any(|id| id == &bp.id),
2868 CraftTab::Tier(t) => bp.craft_tier.max(1) == t,
2869 }
2870 })
2871 .collect();
2872 match self.craft_tab {
2873 CraftTab::Recent => {
2874 idxs.sort_by_key(|&i| {
2875 self.craft_prefs
2876 .recent
2877 .iter()
2878 .position(|id| id == &self.blueprints[i].id)
2879 .unwrap_or(usize::MAX)
2880 });
2881 }
2882 _ => {
2883 idxs.sort_by(|&a, &b| {
2884 let ba = &self.blueprints[a];
2885 let bb = &self.blueprints[b];
2886 let ia = self.craft_blueprint_in_channel(&ba.id);
2887 let ib = self.craft_blueprint_in_channel(&bb.id);
2888 ib.cmp(&ia)
2890 .then_with(|| {
2891 let ra = self.can_craft_blueprint(ba);
2892 let rb = self.can_craft_blueprint(bb);
2893 rb.cmp(&ra)
2894 })
2895 .then_with(|| {
2896 ba.label
2897 .to_ascii_lowercase()
2898 .cmp(&bb.label.to_ascii_lowercase())
2899 })
2900 });
2901 }
2902 }
2903 idxs
2904 }
2905
2906 pub fn craft_selected_blueprint(&self) -> Option<&BlueprintView> {
2907 let idxs = self.craft_filtered_indices();
2908 idxs.get(self.craft_menu_index)
2909 .and_then(|&i| self.blueprints.get(i))
2910 }
2911
2912 pub fn clamp_craft_menu_index(&mut self) {
2913 let n = self.craft_filtered_indices().len();
2914 if n == 0 {
2915 self.craft_menu_index = 0;
2916 } else {
2917 self.craft_menu_index = self.craft_menu_index.min(n - 1);
2918 }
2919 }
2920
2921 pub fn craft_is_favorite(&self, blueprint_id: &str) -> bool {
2922 self.craft_prefs.is_favorite(blueprint_id)
2923 }
2924
2925 pub fn craft_toggle_favorite_selected(&mut self) {
2926 let Some(id) = self.craft_selected_blueprint().map(|bp| bp.id.clone()) else {
2927 return;
2928 };
2929 self.craft_prefs.toggle_favorite(&id);
2930 self.persist_craft_prefs();
2931 if matches!(self.craft_tab, CraftTab::Favorites) {
2932 self.clamp_craft_menu_index();
2933 }
2934 }
2935
2936 pub fn craft_record_completed(&mut self, blueprint_id: &str) {
2937 self.craft_prefs.record_crafted(blueprint_id);
2938 self.persist_craft_prefs();
2939 }
2940
2941 pub fn focus_craft_filter(&mut self) {
2942 self.craft_filter_focused = true;
2943 }
2944
2945 pub fn append_craft_filter_char(&mut self, ch: char) {
2946 if !self.craft_filter_focused {
2947 return;
2948 }
2949 if is_list_filter_char(ch) {
2950 self.craft_filter.push(ch);
2951 self.craft_menu_index = 0;
2952 self.clamp_craft_menu_index();
2953 }
2954 }
2955
2956 pub fn craft_filter_backspace(&mut self) {
2957 if !self.craft_filter_focused {
2958 return;
2959 }
2960 self.craft_filter.pop();
2961 self.craft_menu_index = 0;
2962 self.clamp_craft_menu_index();
2963 }
2964
2965 pub fn clear_or_blur_craft_filter(&mut self) -> bool {
2967 if self.craft_filter_focused {
2968 if !self.craft_filter.is_empty() {
2969 self.craft_filter.clear();
2970 self.craft_menu_index = 0;
2971 self.clamp_craft_menu_index();
2972 } else {
2973 self.craft_filter_focused = false;
2974 }
2975 return true;
2976 }
2977 if !self.craft_filter.is_empty() {
2978 self.craft_filter.clear();
2979 self.craft_menu_index = 0;
2980 self.clamp_craft_menu_index();
2981 return true;
2982 }
2983 false
2984 }
2985
2986 pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
2987 if !self.can_craft_blueprint(blueprint) {
2988 return 0;
2989 }
2990 let mut limit = u32::MAX;
2991 for input in &blueprint.inputs {
2992 if input.quantity == 0 {
2993 continue;
2994 }
2995 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2996 limit = limit.min(have / input.quantity);
2997 }
2998 for tool in &blueprint.required_tools {
2999 if tool.consumed {
3000 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
3001 limit = limit.min(have);
3002 }
3003 }
3004 if self.craft_output_needs_vessel(blueprint) {
3005 let need = blueprint.output_qty.max(1);
3006 let room = self.vessel_room_after_craft_inputs(blueprint);
3007 if need > 0 {
3008 limit = limit.min(room / need);
3009 }
3010 }
3011 limit.min(CRAFT_BATCH_SELECT_CAP)
3012 }
3013
3014 pub fn craft_stamina_batch_cap(&self) -> u32 {
3016 let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
3017 if CRAFT_STAMINA_COST > 0.0 {
3018 (stamina / CRAFT_STAMINA_COST).floor() as u32
3019 } else {
3020 u32::MAX
3021 }
3022 }
3023
3024 pub fn clamp_craft_batch_quantity(&mut self) {
3025 let Some(bp) = self.craft_selected_blueprint().cloned() else {
3026 self.craft_batch_quantity = 1;
3027 return;
3028 };
3029 let max = self.max_craft_batches(&bp).max(1);
3030 self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
3031 }
3032
3033 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
3034 let Some(bp) = self.craft_selected_blueprint().cloned() else {
3035 return;
3036 };
3037 let max = self.max_craft_batches(&bp).max(1);
3038 let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
3039 self.craft_batch_quantity = next as u32;
3040 }
3041
3042 pub fn craft_batch_set_max(&mut self) {
3043 let Some(bp) = self.craft_selected_blueprint().cloned() else {
3044 return;
3045 };
3046 let max = self.max_craft_batches(&bp);
3047 self.craft_batch_quantity = if max == 0 { 1 } else { max };
3048 }
3049
3050 pub fn craft_batch_set_min(&mut self) {
3051 self.craft_batch_quantity = 1;
3052 }
3053
3054 pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
3055 let preserve_ui = self.show_shop_menu;
3056 let tab = self.shop_tab;
3057 let index = self.shop_menu_index;
3058 let qty = self.shop_quantity;
3059
3060 self.show_shop_menu = true;
3061 self.bank_panel = None;
3062 self.show_craft_menu = false;
3063 self.show_inventory_menu = false;
3064 self.show_stats = false;
3065 if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
3066 self.npc_verb_target = Some(catalog.npc_id.clone());
3067 }
3068 self.shop_catalog = Some(catalog);
3069
3070 if preserve_ui {
3071 self.shop_tab = tab;
3072 self.shop_menu_index = index;
3073 self.shop_quantity = qty;
3074 } else {
3075 self.shop_tab = ShopTab::Buy;
3076 self.shop_menu_index = 0;
3077 self.shop_quantity = 1;
3078 self.clear_shop_trade_log();
3079 }
3080 self.show_npc_verb_menu = false;
3081 self.clamp_shop_selection();
3082 }
3083
3084 pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
3085 let same_teller = self
3086 .bank_panel
3087 .as_ref()
3088 .is_some_and(|p| p.npc_id == panel.npc_id);
3089 self.bank_panel = Some(panel);
3090 self.storage_panel = None;
3091 self.market_panel = None;
3092 self.shop_catalog = None;
3093 self.show_shop_menu = false;
3094 self.show_craft_menu = false;
3095 self.show_inventory_menu = false;
3096 self.show_stats = false;
3097 self.show_npc_verb_menu = false;
3098 self.show_npc_chat = false;
3099 self.npc_chat = None;
3100 if !same_teller {
3101 self.bank_menu_index = 0;
3102 self.bank_ui_mode = BankUiMode::Menu;
3103 }
3104 if let Some(panel) = &self.bank_panel {
3105 if self.npc_verb_target.is_none() {
3106 self.npc_verb_target = Some(panel.npc_id.clone());
3107 }
3108 }
3109 }
3110
3111 pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
3112 let same_manager = self
3113 .storage_panel
3114 .as_ref()
3115 .is_some_and(|p| p.npc_id == panel.npc_id);
3116 self.storage_panel = Some(panel);
3117 self.bank_panel = None;
3118 self.market_panel = None;
3119 self.bank_ui_mode = BankUiMode::Menu;
3120 self.shop_catalog = None;
3121 self.show_shop_menu = false;
3122 self.show_craft_menu = false;
3123 self.show_inventory_menu = false;
3124 self.show_stats = false;
3125 self.show_npc_verb_menu = false;
3126 self.show_npc_chat = false;
3127 self.npc_chat = None;
3128 if !same_manager {
3129 self.storage_menu_index = 0;
3130 self.storage_ui_mode = StorageUiMode::Menu;
3131 } else {
3132 self.clamp_storage_pick_index();
3133 }
3134 if let Some(panel) = &self.storage_panel {
3135 if self.npc_verb_target.is_none() {
3136 self.npc_verb_target = Some(panel.npc_id.clone());
3137 }
3138 }
3139 }
3140
3141 pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
3142 for vault in &panel.list_vaults {
3143 self.merge_stack_catalog_hints(&vault.contents);
3144 }
3145 self.market_panel = Some(panel);
3146 self.bank_panel = None;
3147 self.storage_panel = None;
3148 self.shop_catalog = None;
3149 self.show_shop_menu = false;
3150 self.show_craft_menu = false;
3151 self.show_inventory_menu = false;
3152 self.show_stats = false;
3153 self.show_npc_verb_menu = false;
3154 self.show_npc_chat = false;
3155 self.npc_chat = None;
3156 self.market_menu_index = 0;
3157 self.market_buy_confirm = None;
3158 self.market_ui_mode = MarketUiMode::Browse;
3159 self.market_filter.clear();
3160 self.market_filter_focused = false;
3161 self.market_category_filter = None;
3162 if let Some(panel) = &self.market_panel {
3163 if self.npc_verb_target.is_none() {
3164 self.npc_verb_target = Some(panel.npc_id.clone());
3165 }
3166 }
3167 }
3168
3169 pub fn clear_market_panel(&mut self) {
3170 self.market_panel = None;
3171 self.market_menu_index = 0;
3172 self.market_buy_confirm = None;
3173 self.market_ui_mode = MarketUiMode::Browse;
3174 self.market_filter.clear();
3175 self.market_filter_focused = false;
3176 self.market_category_filter = None;
3177 }
3178
3179 pub fn clear_bank_panel(&mut self) {
3180 self.bank_panel = None;
3181 self.bank_menu_index = 0;
3182 self.bank_ui_mode = BankUiMode::Menu;
3183 }
3184
3185 pub fn clear_storage_panel(&mut self) {
3186 self.storage_panel = None;
3187 self.storage_menu_index = 0;
3188 self.storage_ui_mode = StorageUiMode::Menu;
3189 }
3190
3191 fn clamp_storage_pick_index(&mut self) {
3192 match &self.storage_ui_mode {
3193 StorageUiMode::StorePick { index } => {
3194 let n = self.storage_store_options().len();
3195 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3196 self.storage_ui_mode = StorageUiMode::StorePick { index: next };
3197 }
3198 StorageUiMode::TakePick { index } => {
3199 let n = self.storage_vault_options().len();
3200 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3201 self.storage_ui_mode = StorageUiMode::TakePick { index: next };
3202 }
3203 StorageUiMode::ShipPick {
3204 dest_building_id,
3205 dest_label,
3206 index,
3207 } => {
3208 let n = self.storage_vault_options().len();
3209 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3210 self.storage_ui_mode = StorageUiMode::ShipPick {
3211 dest_building_id: dest_building_id.clone(),
3212 dest_label: dest_label.clone(),
3213 index: next,
3214 };
3215 }
3216 StorageUiMode::Menu
3217 | StorageUiMode::StoreAmount { .. }
3218 | StorageUiMode::TakeAmount { .. }
3219 | StorageUiMode::ShipAmount { .. } => {}
3220 }
3221 }
3222
3223 pub fn shop_list_len(&self) -> usize {
3224 let Some(catalog) = &self.shop_catalog else {
3225 return 0;
3226 };
3227 match self.shop_tab {
3228 ShopTab::Buy => catalog.sells.len(),
3229 ShopTab::Sell => catalog.buys.len(),
3230 }
3231 }
3232
3233 pub fn shop_menu_move(&mut self, delta: i32) {
3234 let n = self.shop_list_len();
3235 if n == 0 {
3236 return;
3237 }
3238 let idx = self.shop_menu_index as i32;
3239 let next = (idx + delta).rem_euclid(n as i32);
3240 self.shop_menu_index = next as usize;
3241 self.clamp_shop_quantity();
3242 }
3243
3244 pub fn shop_quantity_adjust(&mut self, delta: i32) {
3245 let max = self.shop_quantity_max();
3246 if max == 0 {
3247 self.shop_quantity = 0;
3248 return;
3249 }
3250 let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
3251 self.shop_quantity = next as u32;
3252 }
3253
3254 pub(crate) fn clamp_shop_selection(&mut self) {
3255 let n = self.shop_list_len();
3256 if n == 0 {
3257 self.shop_menu_index = 0;
3258 } else {
3259 self.shop_menu_index = self.shop_menu_index.min(n - 1);
3260 }
3261 self.clamp_shop_quantity();
3262 }
3263
3264 fn shop_quantity_max(&self) -> u32 {
3265 let Some(catalog) = &self.shop_catalog else {
3266 return 1;
3267 };
3268 match self.shop_tab {
3269 ShopTab::Buy => {
3270 if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
3271 if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
3272 return 1;
3273 }
3274 }
3275 99
3276 }
3277 ShopTab::Sell => catalog
3278 .buys
3279 .get(self.shop_menu_index)
3280 .map(|l| l.quantity)
3281 .unwrap_or(0),
3282 }
3283 }
3284
3285 pub fn shop_quantity_set_max(&mut self) {
3286 self.shop_quantity = self.shop_quantity_max();
3287 }
3288
3289 pub fn shop_quantity_set_min(&mut self) {
3290 let max = self.shop_quantity_max();
3291 self.shop_quantity = if max == 0 { 0 } else { 1 };
3292 }
3293
3294 fn clamp_shop_quantity(&mut self) {
3295 let max = self.shop_quantity_max();
3296 if max == 0 {
3297 self.shop_quantity = 0;
3298 } else {
3299 self.shop_quantity = self.shop_quantity.max(1).min(max);
3300 }
3301 }
3302
3303 pub fn player_at_station_tag(&self, tag: &str) -> bool {
3304 let Some(id) = self.effective_inside_building() else {
3305 return false;
3306 };
3307 self.buildings
3308 .iter()
3309 .find(|b| b.id == id)
3310 .is_some_and(|b| b.tags.iter().any(|t| t == tag))
3311 }
3312
3313 pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
3315 if self.can_craft_blueprint(blueprint) {
3316 return None;
3317 }
3318 let mut missing = Vec::new();
3319 for input in &blueprint.inputs {
3320 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
3321 if have < input.quantity {
3322 let name = self.blueprint_ingredient_label(input);
3323 let vessel_note = if self.inventory_item_category(&input.template_id)
3324 == Some("liquid")
3325 || matches!(input.template_id.as_str(), "water" | "milk")
3326 {
3327 "; fill a bottle/waterskin"
3328 } else if self.inventory_item_category(&input.template_id) == Some("bulk")
3329 || matches!(input.template_id.as_str(), "dirt" | "mud" | "sand")
3330 {
3331 "; scoop into a sack/bucket"
3332 } else {
3333 ""
3334 };
3335 missing.push(format!(
3336 "{}×{} (have {have}{vessel_note})",
3337 input.quantity, name
3338 ));
3339 }
3340 }
3341 for tool in &blueprint.required_tools {
3342 if !self.player_has_craft_tool(&tool.item) {
3343 missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
3344 }
3345 }
3346 if let Some(station) = blueprint.station.as_deref() {
3347 if station != "hand" && !self.player_at_station_tag(station) {
3348 missing.push(format!("station: {station} (enter building)"));
3349 }
3350 }
3351 if self.craft_output_needs_vessel(blueprint)
3352 && !self.craft_has_vessel_room_for_output(blueprint)
3353 {
3354 let name = self
3355 .inventory_hints
3356 .get(&blueprint.output)
3357 .map(|h| h.display_name.as_str())
3358 .unwrap_or(blueprint.output.as_str());
3359 let need = blueprint.output_qty.max(1);
3360 let free = self.vessel_room_after_craft_inputs(blueprint);
3361 let accepting = self
3362 .craft_vessel_status(blueprint)
3363 .vessels
3364 .iter()
3365 .filter(|v| v.accepts_output)
3366 .count();
3367 missing.push(format!(
3368 "vessel room for {name}: need {need} free after inputs, have {free} ({accepting} accepting vessel(s))"
3369 ));
3370 }
3371 if missing.is_empty() {
3372 None
3373 } else {
3374 Some(missing.join(", "))
3375 }
3376 }
3377
3378 pub fn active_craft_channel(&self) -> Option<&flatland_protocol::TimedChannelHud> {
3380 self.timed_channel
3381 .as_ref()
3382 .filter(|c| c.channel == flatland_protocol::TimedChannelKind::Craft)
3383 }
3384
3385 pub fn player_entity(&self) -> Option<&EntityState> {
3386 self.player
3387 .as_ref()
3388 .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
3389 }
3390
3391 pub fn apply_client_ui_prefs(&mut self) {
3393 let cfg = crate::client_config::ClientConfig::load();
3394 if let Some(hidden) = cfg.hud_log_hidden {
3395 self.hud_log_hidden = hidden;
3396 }
3397 if let Some(compact) = cfg.workers_menu_compact {
3398 self.workers_menu_compact = compact;
3399 }
3400 }
3401
3402 pub fn player_position(&self) -> (f32, f32) {
3403 let (x, y, _) = self.player_position_with_z();
3404 (x, y)
3405 }
3406
3407 pub fn player_position_with_z(&self) -> (f32, f32, f32) {
3408 if let Some(p) = self.player_entity() {
3409 (
3410 p.transform.position.x,
3411 p.transform.position.y,
3412 p.transform.position.z,
3413 )
3414 } else {
3415 (0.0, 0.0, 0.0)
3416 }
3417 }
3418
3419 pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
3420 let mut rows: Vec<(String, u32, String)> = self
3421 .inventory
3422 .iter()
3423 .filter(|(_, q)| **q > 0)
3424 .map(|(id, qty)| {
3425 let label = self
3426 .inventory_hints
3427 .get(id)
3428 .map(|h| h.display_name.clone())
3429 .unwrap_or_else(|| id.clone());
3430 (id.clone(), *qty, label)
3431 })
3432 .collect();
3433 rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
3434 rows
3435 }
3436
3437 pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
3438 self.inventory_hints
3439 .get(template_id)
3440 .map(|h| h.category.as_str())
3441 .filter(|c| !c.is_empty())
3442 }
3443
3444 pub fn stack_is_serving(stack: &flatland_protocol::ItemStack) -> bool {
3445 stack.props.get("serving").is_some_and(|v| v == "1") || Self::stack_is_liquid_vessel(stack)
3446 }
3447
3448 pub fn stack_is_liquid_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3449 stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
3450 || stack
3451 .props
3452 .get("serving_holds")
3453 .is_some_and(|v| v.split(',').any(|p| p.trim() == "liquid"))
3454 }
3455
3456 pub fn stack_is_food_serving(stack: &flatland_protocol::ItemStack) -> bool {
3457 stack
3458 .props
3459 .get("serving_holds")
3460 .is_some_and(|v| v.split(',').any(|p| p.trim() == "food"))
3461 }
3462
3463 pub fn stack_is_bulk_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3464 stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
3465 || stack
3466 .props
3467 .get("serving_holds")
3468 .is_some_and(|v| v.split(',').any(|p| p.trim() == "bulk"))
3469 }
3470
3471 pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
3472 stack
3473 .props
3474 .get("grants_item_status_effect")
3475 .map(|s| !s.is_empty())
3476 .unwrap_or(false)
3477 }
3478
3479 pub fn stack_is_blueprint_scroll(stack: &flatland_protocol::ItemStack) -> bool {
3480 stack
3481 .props
3482 .get("teaches_blueprint")
3483 .map(|s| !s.trim().is_empty())
3484 .unwrap_or(false)
3485 }
3486
3487 pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
3488 stack
3489 .props
3490 .get("grants_item_status_effect")
3491 .map(String::as_str)
3492 .filter(|s| !s.is_empty())
3493 }
3494
3495 pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
3496 stack
3497 .props
3498 .get("grants_item_status_mode")
3499 .map(String::as_str)
3500 .unwrap_or("on_hit")
3501 }
3502
3503 pub fn grant_target_options(
3505 &self,
3506 grant: &flatland_protocol::ItemStack,
3507 ) -> Vec<GrantTargetOption> {
3508 let mode = Self::grant_mode(grant);
3509 let grant_tags: Vec<&str> = grant
3510 .props
3511 .get("grants_item_status_tags")
3512 .map(|s| {
3513 s.split(',')
3514 .map(str::trim)
3515 .filter(|t| !t.is_empty())
3516 .collect()
3517 })
3518 .unwrap_or_default();
3519 let grant_id = grant.item_instance_id;
3520 let mut out = Vec::new();
3521 let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
3522 let Some(iid) = stack.item_instance_id else {
3523 return;
3524 };
3525 if Some(iid) == grant_id {
3526 return;
3527 }
3528 if stack.props.get("enchantable").map(String::as_str) == Some("0") {
3529 return;
3530 }
3531 if !grant_target_matches_mode(stack, mode) {
3532 return;
3533 }
3534 if !grant_tags_match(stack, &grant_tags) {
3535 return;
3536 }
3537 let name = stack
3538 .display_name
3539 .clone()
3540 .unwrap_or_else(|| stack.template_id.clone());
3541 let bindings = if stack.status_bindings.is_empty() {
3542 String::new()
3543 } else {
3544 format!(
3545 " · {}",
3546 stack
3547 .status_bindings
3548 .iter()
3549 .map(|b| b.effect_id.as_str())
3550 .collect::<Vec<_>>()
3551 .join(", ")
3552 )
3553 };
3554 out.push(GrantTargetOption {
3555 label: format!("{where_label}: {name}{bindings}"),
3556 target_instance_id: iid,
3557 });
3558 };
3559 fn walk(
3560 stacks: &[flatland_protocol::ItemStack],
3561 where_label: &str,
3562 push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
3563 ) {
3564 for s in stacks {
3565 push(s, where_label);
3566 if !s.contents.is_empty() {
3567 let nested = format!(
3568 "{where_label}/{}",
3569 s.display_name.as_deref().unwrap_or(s.template_id.as_str())
3570 );
3571 walk(&s.contents, &nested, push);
3572 }
3573 }
3574 }
3575 walk(&self.inventory_stacks, "Bag", &mut push);
3576 for (slot, stack) in &self.worn {
3577 push(stack, body_slot_label(*slot));
3578 let nest = format!(
3579 "{}/{}",
3580 body_slot_label(*slot),
3581 stack
3582 .display_name
3583 .as_deref()
3584 .unwrap_or(stack.template_id.as_str())
3585 );
3586 walk(&stack.contents, &nest, &mut push);
3587 }
3588 out
3589 }
3590
3591 pub fn item_base_mass(&self, template_id: &str) -> f32 {
3592 self.inventory_hints
3593 .get(template_id)
3594 .and_then(|h| h.base_mass)
3595 .unwrap_or(0.5)
3596 }
3597
3598 pub fn item_base_volume(&self, template_id: &str) -> f32 {
3599 self.inventory_hints
3600 .get(template_id)
3601 .and_then(|h| h.base_volume)
3602 .unwrap_or(1.0)
3603 }
3604
3605 pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
3606 let unit = stack
3607 .base_mass
3608 .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
3609 unit * stack.quantity as f32
3610 }
3611
3612 fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
3613 let unit = stack.base_volume.unwrap_or(1.0);
3614 unit * stack.quantity as f32
3615 + stack
3616 .contents
3617 .iter()
3618 .map(Self::stack_tree_volume)
3619 .sum::<f32>()
3620 }
3621
3622 fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
3623 contents.iter().map(Self::stack_tree_volume).sum()
3624 }
3625
3626 fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
3627 self.inventory_hints
3628 .get(template_id)
3629 .and_then(|h| h.capacity_volume)
3630 .filter(|c| *c > 0.0)
3631 }
3632
3633 fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
3634 stack
3635 .capacity_volume
3636 .filter(|c| *c > 0.0)
3637 .or_else(|| self.template_capacity_volume(&stack.template_id))
3638 }
3639
3640 pub fn container_volume_label(&self, row: &InventoryRow) -> String {
3642 let Some((used, cap)) = self.container_volume_stats(row) else {
3643 return String::new();
3644 };
3645 format!(" {}", format_container_volume_usage(used, cap))
3646 }
3647
3648 fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
3649 if row.is_chest_shell {
3650 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
3651 return None;
3652 };
3653 let chest = self
3654 .placed_containers
3655 .iter()
3656 .find(|c| c.id == *container_id)?;
3657 let cap = self
3658 .stack_capacity_volume(&row.stack)
3659 .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
3660 let used = if chest.accessible {
3661 Self::contents_used_volume(&chest.contents)
3662 } else {
3663 0.0
3664 };
3665 return Some((used, cap));
3666 }
3667
3668 let cap = self.stack_capacity_volume(&row.stack)?;
3669 let used = Self::contents_used_volume(&row.stack.contents);
3670 Some((used, cap))
3671 }
3672
3673 fn destination_volume_stats(
3674 &self,
3675 location: &flatland_protocol::InventoryLocation,
3676 parent_instance_id: Option<uuid::Uuid>,
3677 ) -> Option<(f32, f32)> {
3678 let parent = self.container_stack_for(location, parent_instance_id)?;
3679 let mut cap = self.stack_capacity_volume(&parent);
3680 if cap.is_none() {
3681 if let flatland_protocol::InventoryLocation::Placed { container_id } = location {
3682 if let Some(chest) = self
3683 .placed_containers
3684 .iter()
3685 .find(|c| c.id == *container_id)
3686 {
3687 let looking_at_shell =
3688 parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id));
3689 if looking_at_shell {
3690 cap = chest.capacity_volume.filter(|v| *v > 0.0);
3691 }
3692 }
3693 }
3694 }
3695 let cap = cap?;
3696 let used = Self::contents_used_volume(&parent.contents).max(0.0);
3697 Some((used, cap))
3698 }
3699
3700 pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
3701 if row.is_chest_shell {
3702 return true;
3703 }
3704 if row.is_equip_shell {
3705 return self.inventory_item_category(&row.stack.template_id) == Some("container");
3706 }
3707 self.inventory_item_category(&row.stack.template_id) == Some("container")
3708 || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
3709 }
3710
3711 fn container_stack_for(
3712 &self,
3713 location: &flatland_protocol::InventoryLocation,
3714 parent_instance_id: Option<uuid::Uuid>,
3715 ) -> Option<flatland_protocol::ItemStack> {
3716 match location {
3717 flatland_protocol::InventoryLocation::Root => {
3718 let pid = parent_instance_id?;
3719 self.find_stack_by_instance(&self.inventory_stacks, pid)
3720 }
3721 flatland_protocol::InventoryLocation::Worn { slot } => {
3722 let worn = self.worn.get(slot)?;
3723 if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
3724 Some(worn.clone())
3725 } else {
3726 self.find_stack_by_instance(&worn.contents, parent_instance_id?)
3727 }
3728 }
3729 flatland_protocol::InventoryLocation::Placed { container_id } => {
3730 let chest = self
3731 .placed_containers
3732 .iter()
3733 .find(|c| c.id == *container_id)?;
3734 if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
3735 Some(flatland_protocol::ItemStack {
3736 template_id: chest.template_id.clone(),
3737 quantity: 1,
3738 item_instance_id: chest.item_instance_id,
3739 props: Default::default(),
3740 status_bindings: Vec::new(),
3741 contents: chest.contents.clone(),
3742 display_name: Some(chest.display_name.clone()),
3743 category: Some("container".into()),
3744 capacity_volume: self
3745 .inventory_hints
3746 .get(&chest.template_id)
3747 .and_then(|h| h.capacity_volume),
3748 worker_lodging_capacity: chest.worker_lodging_capacity,
3749 ..Default::default()
3750 })
3751 } else {
3752 self.find_stack_by_instance(&chest.contents, parent_instance_id?)
3753 }
3754 }
3755 flatland_protocol::InventoryLocation::Keychain => None,
3756 flatland_protocol::InventoryLocation::WhisperPouch => None,
3757 }
3758 }
3759
3760 fn find_stack_by_instance(
3761 &self,
3762 stacks: &[flatland_protocol::ItemStack],
3763 instance_id: uuid::Uuid,
3764 ) -> Option<flatland_protocol::ItemStack> {
3765 for stack in stacks {
3766 if stack.item_instance_id == Some(instance_id) {
3767 return Some(stack.clone());
3768 }
3769 if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
3770 return Some(found);
3771 }
3772 }
3773 None
3774 }
3775
3776 pub fn max_movable_to(
3778 &self,
3779 template_id: &str,
3780 stack_qty: u32,
3781 from: &flatland_protocol::InventoryLocation,
3782 to: &flatland_protocol::InventoryLocation,
3783 parent_instance_id: Option<uuid::Uuid>,
3784 ) -> u32 {
3785 let unit_vol = self.item_base_volume(template_id);
3786 let mut limit = stack_qty;
3787
3788 if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
3789 let cap = parent
3790 .capacity_volume
3791 .or_else(|| {
3792 self.inventory_hints
3793 .get(&parent.template_id)
3794 .and_then(|h| h.capacity_volume)
3795 })
3796 .unwrap_or(0.0);
3797 if cap > 0.0 && unit_vol > 0.0 {
3798 let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
3799 limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
3800 }
3801 }
3802
3803 let _ = from;
3804 limit.max(0).min(stack_qty)
3805 }
3806
3807 pub fn move_picker_max_at_selection(&self) -> u32 {
3808 let Some(picker) = &self.move_picker else {
3809 return 1;
3810 };
3811 let Some(opt) = picker.options.get(self.move_picker_index) else {
3812 return picker.stack_quantity;
3813 };
3814 match &opt.kind {
3815 MoveOptionKind::Cancel
3816 | MoveOptionKind::Drop
3817 | MoveOptionKind::Use
3818 | MoveOptionKind::GrantApply
3819 | MoveOptionKind::SellPlotToCrown { .. }
3820 | MoveOptionKind::PickupPlaced { .. }
3821 | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
3822 MoveOptionKind::Move {
3823 location,
3824 parent_instance_id,
3825 } => self.max_movable_to(
3826 &picker.template_id,
3827 picker.stack_quantity,
3828 &picker.from,
3829 location,
3830 *parent_instance_id,
3831 ),
3832 }
3833 }
3834
3835 pub fn clamp_move_picker_quantity(&mut self) {
3836 let max = self.move_picker_max_at_selection();
3837 if let Some(picker) = &mut self.move_picker {
3838 if max == 0 {
3839 picker.quantity = 1;
3840 } else {
3841 picker.quantity = picker.quantity.clamp(1, max);
3842 }
3843 }
3844 }
3845
3846 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
3847 let max = self.move_picker_max_at_selection().max(1);
3848 if let Some(picker) = &mut self.move_picker {
3849 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3850 picker.quantity = next as u32;
3851 }
3852 }
3853
3854 pub fn move_picker_set_quantity_max(&mut self) {
3855 let max = self.move_picker_max_at_selection();
3856 if let Some(picker) = &mut self.move_picker {
3857 picker.quantity = if max == 0 {
3858 1
3859 } else {
3860 max.min(picker.stack_quantity)
3861 };
3862 }
3863 }
3864
3865 pub fn move_picker_set_quantity_min(&mut self) {
3866 if let Some(picker) = &mut self.move_picker {
3867 picker.quantity = 1;
3868 }
3869 }
3870
3871 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
3872 if let Some(picker) = &mut self.destroy_picker {
3873 let max = picker.stack_quantity.max(1);
3874 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3875 picker.quantity = next as u32;
3876 }
3877 }
3878
3879 pub fn destroy_picker_set_quantity_max(&mut self) {
3880 if let Some(picker) = &mut self.destroy_picker {
3881 picker.quantity = picker.stack_quantity.max(1);
3882 }
3883 }
3884
3885 pub fn destroy_picker_set_quantity_min(&mut self) {
3886 if let Some(picker) = &mut self.destroy_picker {
3887 picker.quantity = 1;
3888 }
3889 }
3890
3891 pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3892 let have = self.inventory.get(template_id).copied().unwrap_or(0);
3893 (have, have >= need)
3894 }
3895
3896 pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3898 let have = self
3899 .plot_build_offer
3900 .as_ref()
3901 .and_then(|o| {
3902 o.available
3903 .iter()
3904 .find(|s| s.template_id == template_id)
3905 .map(|s| s.quantity)
3906 })
3907 .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
3908 (have, have >= need)
3909 }
3910
3911 pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3912 self.building_materials
3913 .iter()
3914 .filter(|m| m.can_wall)
3915 .collect()
3916 }
3917
3918 pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3919 self.building_materials
3920 .iter()
3921 .filter(|m| m.can_roof)
3922 .collect()
3923 }
3924
3925 pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3926 self.plot_build_wall_options()
3927 .get(self.plot_build_wall_index)
3928 .copied()
3929 }
3930
3931 pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3932 self.plot_build_roof_options()
3933 .get(self.plot_build_roof_index)
3934 .copied()
3935 }
3936
3937 pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
3939 let Some(wall) = self.plot_build_selected_wall() else {
3940 return Vec::new();
3941 };
3942 let Some(roof) = self.plot_build_selected_roof() else {
3943 return Vec::new();
3944 };
3945 let area = self
3946 .plot_build_offer
3947 .as_ref()
3948 .filter(|o| o.pad_ok)
3949 .map(|o| o.pad_width_m * o.pad_depth_m)
3950 .unwrap_or(0.0);
3951 if area <= 0.0 {
3952 return Vec::new();
3953 }
3954 let mut map: std::collections::HashMap<String, (String, u32)> =
3955 std::collections::HashMap::new();
3956 for line in &wall.wall_bom {
3957 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3958 if qty == 0 {
3959 continue;
3960 }
3961 let name = if line.display_name.is_empty() {
3962 line.template_id.clone()
3963 } else {
3964 line.display_name.clone()
3965 };
3966 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3967 entry.1 = entry.1.saturating_add(qty);
3968 }
3969 for line in &roof.roof_bom {
3970 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3971 if qty == 0 {
3972 continue;
3973 }
3974 let name = if line.display_name.is_empty() {
3975 line.template_id.clone()
3976 } else {
3977 line.display_name.clone()
3978 };
3979 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3980 entry.1 = entry.1.saturating_add(qty);
3981 }
3982 let mut out: Vec<_> = map
3983 .into_iter()
3984 .map(|(id, (name, qty))| (id, name, qty))
3985 .collect();
3986 out.sort_by(|a, b| a.0.cmp(&b.0));
3987 out
3988 }
3989
3990 pub fn plot_build_duration_secs(&self) -> Option<f32> {
3991 let wall = self.plot_build_selected_wall()?;
3992 let roof = self.plot_build_selected_roof()?;
3993 let offer = self.plot_build_offer.as_ref()?;
3994 if !offer.pad_ok {
3995 return None;
3996 }
3997 let area = offer.pad_width_m * offer.pad_depth_m;
3998 let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
3999 let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
4000 Some(ticks.max(2.0) / 30.0)
4001 }
4002
4003 pub fn plot_build_can_afford(&self) -> bool {
4004 if self.plot_build_offer.as_ref().is_none_or(|o| !o.pad_ok) {
4005 return false;
4006 }
4007 self.plot_build_bom_lines()
4008 .iter()
4009 .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
4010 }
4011
4012 pub fn currency_display(&self) -> String {
4013 crate::currency::currency_line(&self.inventory)
4014 }
4015
4016 pub fn in_shallow_water(&self) -> bool {
4018 let (px, py) = self.player_position();
4019 self.terrain_at(px, py)
4020 .is_some_and(|k| k == TerrainKindView::ShallowWater)
4021 }
4022
4023 pub fn near_liquid_fill_source(&self) -> bool {
4025 let (px, py) = self.player_position();
4026 const CELL: f32 = 1.0;
4027 let offsets = [
4028 (0.0, 0.0),
4029 (CELL, 0.0),
4030 (-CELL, 0.0),
4031 (0.0, CELL),
4032 (0.0, -CELL),
4033 ];
4034 for (dx, dy) in offsets {
4035 if matches!(
4036 self.terrain_at(px + dx, py + dy),
4037 Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
4038 ) {
4039 return true;
4040 }
4041 }
4042 self.buildings.iter().any(|b| {
4043 if !b.tags.iter().any(|t| t == "well") {
4044 return false;
4045 }
4046 let hw = b.width_m * 0.5;
4047 let hd = b.depth_m * 0.5;
4048 let nx = px.clamp(b.x - hw, b.x + hw);
4049 let ny = py.clamp(b.y - hd, b.y + hd);
4050 let dx = px - nx;
4051 let dy = py - ny;
4052 dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
4053 })
4054 }
4055
4056 pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
4057 self.terrain_zone_at(x, y).map(|z| z.kind)
4058 }
4059
4060 pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
4062 use std::cell::RefCell;
4063
4064 const CHUNK: i32 = 8;
4065 thread_local! {
4066 static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
4067 RefCell::new(None);
4068 }
4069
4070 let zones = &self.terrain_zones;
4071 if zones.is_empty() {
4072 return None;
4073 }
4074 if zones.len() <= 48 {
4075 return zones
4076 .iter()
4077 .enumerate()
4078 .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
4079 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
4080 .map(|(_, z)| z);
4081 }
4082
4083 let ptr = zones.as_ptr();
4084 let len = zones.len();
4085 INDEX.with(|cell| {
4086 let mut slot = cell.borrow_mut();
4087 let stale = match slot.as_ref() {
4088 Some((p, l, _)) => *p != ptr || *l != len,
4089 None => true,
4090 };
4091 if stale {
4092 let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
4093 std::collections::HashMap::new();
4094 for (zi, z) in zones.iter().enumerate() {
4095 let x0 = z.x0.min(z.x1).floor() as i32;
4096 let y0 = z.y0.min(z.y1).floor() as i32;
4097 let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
4098 let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
4099 let cx0 = x0.div_euclid(CHUNK);
4100 let cy0 = y0.div_euclid(CHUNK);
4101 let cx1 = x1.div_euclid(CHUNK);
4102 let cy1 = y1.div_euclid(CHUNK);
4103 for cy in cy0..=cy1 {
4104 for cx in cx0..=cx1 {
4105 chunks.entry((cx, cy)).or_default().push(zi);
4106 }
4107 }
4108 }
4109 *slot = Some((ptr, len, chunks));
4110 }
4111 let chunks = &slot.as_ref().expect("index").2;
4112 let cx = (x.floor() as i32).div_euclid(CHUNK);
4113 let cy = (y.floor() as i32).div_euclid(CHUNK);
4114 let mut best: Option<(usize, &TerrainZoneView)> = None;
4115 if let Some(list) = chunks.get(&(cx, cy)) {
4116 for &zi in list {
4117 let Some(z) = zones.get(zi) else { continue };
4118 if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
4119 continue;
4120 }
4121 best = match best {
4122 None => Some((zi, z)),
4123 Some((bi, bz)) => {
4124 if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
4125 Some((zi, z))
4126 } else {
4127 Some((bi, bz))
4128 }
4129 }
4130 };
4131 }
4132 }
4133 best.map(|(_, z)| z)
4134 })
4135 }
4136
4137 pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
4139 self.terrain_zone_at(x, y)
4140 .map(|z| z.elevation)
4141 .unwrap_or(0.0)
4142 }
4143
4144 pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
4146 const TOL: f32 = 0.35;
4147 let mut levels = vec![self.elevation_at(x, y)];
4148 for p in &self.z_platforms {
4149 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4150 levels.push(p.z);
4151 }
4152 }
4153 levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
4154 levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
4155 levels
4156 }
4157
4158 pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
4159 const TOL: f32 = 0.35;
4160 self.walkable_levels_at(x, y)
4161 .iter()
4162 .any(|&l| (l - z).abs() <= TOL)
4163 }
4164
4165 pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
4166 let mut top = self.elevation_at(x, y);
4167 for p in &self.z_platforms {
4168 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4169 top = top.max(p.z);
4170 }
4171 }
4172 top
4173 }
4174
4175 pub fn effective_inside_building(&self) -> Option<String> {
4177 self.player_entity().and_then(|p| p.inside_building.clone())
4178 }
4179
4180 pub fn placed_container_in_current_space(
4184 &self,
4185 c: &flatland_protocol::PlacedContainerView,
4186 ) -> bool {
4187 match (
4188 self.effective_inside_building().as_deref(),
4189 c.building_id.as_deref(),
4190 ) {
4191 (None, None) => true,
4192 (Some(a), Some(b)) => a == b,
4193 _ => false,
4194 }
4195 }
4196
4197 fn merge_stack_catalog_hints(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4198 fn walk(
4199 stacks: &[flatland_protocol::ItemStack],
4200 hints: &mut std::collections::HashMap<String, InventoryHint>,
4201 ) {
4202 for stack in stacks {
4203 if stack.display_name.is_some()
4204 || stack.category.is_some()
4205 || stack.base_mass.is_some()
4206 || stack.base_volume.is_some()
4207 || stack.base_value_copper.is_some()
4208 {
4209 hints.insert(
4210 stack.template_id.clone(),
4211 InventoryHint {
4212 display_name: stack
4213 .display_name
4214 .clone()
4215 .unwrap_or_else(|| stack.template_id.clone()),
4216 category: stack.category.clone().unwrap_or_default(),
4217 base_mass: stack.base_mass,
4218 base_volume: stack.base_volume,
4219 capacity_volume: stack.capacity_volume,
4220 stackable: stack.stackable.unwrap_or(true),
4221 listable: stack.listable.unwrap_or_else(|| {
4222 category_default_listable(stack.category.as_deref().unwrap_or(""))
4223 }),
4224 base_value_copper: stack.base_value_copper,
4225 },
4226 );
4227 }
4228 walk(&stack.contents, hints);
4229 }
4230 }
4231 walk(stacks, &mut self.inventory_hints);
4232 }
4233
4234 pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4235 self.inventory_stacks = stacks.to_vec();
4236 self.inventory.clear();
4237 self.inventory_hints.clear();
4238 fn walk(
4239 stacks: &[flatland_protocol::ItemStack],
4240 inventory: &mut std::collections::HashMap<String, u32>,
4241 hints: &mut std::collections::HashMap<String, InventoryHint>,
4242 ) {
4243 for stack in stacks {
4244 *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
4245 if stack.display_name.is_some()
4246 || stack.category.is_some()
4247 || stack.base_mass.is_some()
4248 || stack.base_volume.is_some()
4249 || stack.base_value_copper.is_some()
4250 {
4251 hints.insert(
4252 stack.template_id.clone(),
4253 InventoryHint {
4254 display_name: stack
4255 .display_name
4256 .clone()
4257 .unwrap_or_else(|| stack.template_id.clone()),
4258 category: stack.category.clone().unwrap_or_default(),
4259 base_mass: stack.base_mass,
4260 base_volume: stack.base_volume,
4261 capacity_volume: stack.capacity_volume,
4262 stackable: stack.stackable.unwrap_or(true),
4263 listable: stack.listable.unwrap_or_else(|| {
4264 category_default_listable(stack.category.as_deref().unwrap_or(""))
4265 }),
4266 base_value_copper: stack.base_value_copper,
4267 },
4268 );
4269 }
4270 walk(&stack.contents, inventory, hints);
4271 }
4272 }
4273 walk(stacks, &mut self.inventory, &mut self.inventory_hints);
4274 for item in self.worn.values() {
4276 walk(
4277 std::slice::from_ref(item),
4278 &mut self.inventory,
4279 &mut self.inventory_hints,
4280 );
4281 }
4282 }
4283
4284 fn sync_item_catalog(&mut self, entries: &[ItemCatalogEntryView]) {
4285 if entries.is_empty() {
4286 return;
4287 }
4288 self.item_catalog.clear();
4289 self.item_catalog.reserve(entries.len());
4290 for entry in entries {
4291 if entry.template_id.is_empty() {
4292 continue;
4293 }
4294 self.item_catalog
4295 .insert(entry.template_id.clone(), entry.clone());
4296 }
4297 }
4298
4299 fn remove_carried_instance(&mut self, instance_id: uuid::Uuid, quantity: Option<u32>) {
4303 fn take_from(
4304 stacks: &mut Vec<flatland_protocol::ItemStack>,
4305 instance_id: uuid::Uuid,
4306 qty: Option<u32>,
4307 ) -> bool {
4308 if let Some(i) = stacks
4309 .iter()
4310 .position(|s| s.item_instance_id == Some(instance_id))
4311 {
4312 let have = stacks[i].quantity;
4313 let take = qty.unwrap_or(have).min(have);
4314 if take >= have {
4315 stacks.remove(i);
4316 } else {
4317 stacks[i].quantity = have - take;
4318 }
4319 return true;
4320 }
4321 stacks
4322 .iter_mut()
4323 .any(|stack| take_from(&mut stack.contents, instance_id, qty))
4324 }
4325
4326 if take_from(&mut self.inventory_stacks, instance_id, quantity) {
4327 let stacks = self.inventory_stacks.clone();
4328 self.sync_inventory_from_stacks(&stacks);
4329 self.refresh_inventory_ui();
4330 return;
4331 }
4332 let slots: Vec<_> = self.worn.keys().copied().collect();
4333 for slot in slots {
4334 let Some(item) = self.worn.get_mut(&slot) else {
4335 continue;
4336 };
4337 if take_from(&mut item.contents, instance_id, quantity) {
4338 let stacks = self.inventory_stacks.clone();
4339 self.sync_inventory_from_stacks(&stacks);
4340 self.refresh_inventory_ui();
4341 return;
4342 }
4343 }
4344 }
4345
4346 pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
4349 if notice.message.starts_with("Gave ") {
4353 if notice.coins_delta != 0 {
4354 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4355 let stacks = self.inventory_stacks.clone();
4356 self.sync_inventory_from_stacks(&stacks);
4357 }
4358 self.record_shop_trade_notice(notice);
4359 return;
4360 }
4361 let subtract_items =
4362 notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
4363 for stack in ¬ice.inventory_delta {
4364 if stack.quantity == 0 {
4365 continue;
4366 }
4367 if subtract_items {
4368 crate::currency::drain_template_stacks(
4369 &mut self.inventory_stacks,
4370 &stack.template_id,
4371 stack.quantity,
4372 );
4373 continue;
4374 }
4375 let stackable = self
4376 .inventory_hints
4377 .get(&stack.template_id)
4378 .map(|h| h.stackable)
4379 .or(stack.stackable)
4380 .unwrap_or(true);
4381 if stackable {
4382 if let Some(existing) = self
4383 .inventory_stacks
4384 .iter_mut()
4385 .find(|s| s.template_id == stack.template_id)
4386 {
4387 existing.quantity = existing.quantity.saturating_add(stack.quantity);
4388 if stack.display_name.is_some() {
4389 existing.display_name = stack.display_name.clone();
4390 }
4391 if stack.category.is_some() {
4392 existing.category = stack.category.clone();
4393 }
4394 continue;
4395 }
4396 }
4397 self.inventory_stacks.push(stack.clone());
4398 }
4399 if notice.coins_delta != 0 {
4400 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4401 }
4402 if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
4403 let stacks = self.inventory_stacks.clone();
4404 self.sync_inventory_from_stacks(&stacks);
4405 }
4406 self.record_shop_trade_notice(notice);
4407 }
4408
4409 pub fn worn_rows(&self) -> Vec<InventoryRow> {
4414 let mut rows = Vec::new();
4415 for (slot, item) in &self.worn {
4416 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4417 rows.push(InventoryRow {
4418 depth: 0,
4419 stack: item.clone(),
4420 from: from.clone(),
4421 from_parent_instance_id: None,
4422 is_equip_shell: true,
4423 is_chest_shell: false,
4424 section: InventorySection::Worn,
4425 });
4426 for child in &item.contents {
4427 push_inventory_rows(
4428 &mut rows,
4429 1,
4430 child,
4431 &from,
4432 item.item_instance_id,
4433 InventorySection::Worn,
4434 );
4435 }
4436 }
4437 rows
4438 }
4439
4440 pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
4442 let equipped = self.hand_equipped_instance_ids();
4443 self.inventory_stacks
4444 .iter()
4445 .filter(|s| s.item_instance_id.is_some_and(|id| !equipped.contains(&id)))
4446 .collect()
4447 }
4448
4449 pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
4451 let equipped = self.hand_equipped_instance_ids();
4452 self.inventory_stacks
4453 .iter()
4454 .filter_map(|stack| {
4455 let item_instance_id = stack.item_instance_id?;
4456 if equipped.contains(&item_instance_id) {
4457 return None;
4458 }
4459 let label = stack
4460 .display_name
4461 .clone()
4462 .unwrap_or_else(|| stack.template_id.clone());
4463 let label = if stack.quantity > 1 {
4464 format!("{label} ×{}", stack.quantity)
4465 } else {
4466 label
4467 };
4468 Some(WorkerGiveOption {
4469 item_instance_id,
4470 label,
4471 quantity: stack.quantity,
4472 template_id: stack.template_id.clone(),
4473 })
4474 })
4475 .collect()
4476 }
4477
4478 pub fn teachable_blueprint_options(
4480 &self,
4481 worker: &flatland_protocol::HiredWorkerView,
4482 ) -> Vec<WorkerTeachOption> {
4483 let copper = crate::currency::copper_from_counts(&self.inventory);
4484 let mut options: Vec<WorkerTeachOption> = self
4485 .blueprints
4486 .iter()
4487 .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
4488 .map(|bp| {
4489 let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
4490 let cost = bp.worker_train_copper;
4491 WorkerTeachOption {
4492 blueprint_id: bp.id.clone(),
4493 label: if bp.label.is_empty() {
4494 bp.id.clone()
4495 } else {
4496 bp.label.clone()
4497 },
4498 cost_copper: cost,
4499 min_level,
4500 worker_level: worker.level,
4501 can_afford: copper >= cost,
4502 level_ok: worker.level >= min_level,
4503 }
4504 })
4505 .collect();
4506 options.sort_by(|a, b| a.label.cmp(&b.label));
4507 options
4508 }
4509
4510 pub fn person_rows(&self) -> Vec<InventoryRow> {
4513 self.person_rows_filtered("")
4514 }
4515
4516 pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4517 let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
4518 roots.sort_by(|a, b| {
4519 let ca = a
4520 .category
4521 .as_deref()
4522 .or_else(|| self.inventory_item_category(&a.template_id))
4523 .unwrap_or("");
4524 let cb = b
4525 .category
4526 .as_deref()
4527 .or_else(|| self.inventory_item_category(&b.template_id))
4528 .unwrap_or("");
4529 let ga = inventory_category_group(ca).1;
4530 let gb = inventory_category_group(cb).1;
4531 ga.cmp(&gb).then_with(|| {
4532 let na = a.display_name.as_deref().unwrap_or(a.template_id.as_str());
4533 let nb = b.display_name.as_deref().unwrap_or(b.template_id.as_str());
4534 na.cmp(nb)
4535 })
4536 });
4537 let mut rows = Vec::new();
4538 for stack in roots {
4539 push_inventory_rows_filtered(
4540 &mut rows,
4541 0,
4542 stack,
4543 &flatland_protocol::InventoryLocation::Root,
4544 None,
4545 InventorySection::Person,
4546 filter,
4547 );
4548 }
4549 rows
4550 }
4551
4552 pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4553 if filter.is_empty() {
4554 return self.worn_rows();
4555 }
4556 let mut rows = Vec::new();
4557 for (slot, item) in &self.worn {
4558 if !stack_matches_filter(item, filter) {
4559 continue;
4560 }
4561 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4562 let self_hit = {
4563 let f = filter.to_ascii_lowercase();
4564 let name = item
4565 .display_name
4566 .as_deref()
4567 .unwrap_or("")
4568 .to_ascii_lowercase();
4569 let tid = item.template_id.to_ascii_lowercase();
4570 name.contains(&f) || tid.contains(&f)
4571 };
4572 rows.push(InventoryRow {
4573 depth: 0,
4574 stack: item.clone(),
4575 from: from.clone(),
4576 from_parent_instance_id: None,
4577 is_equip_shell: true,
4578 is_chest_shell: false,
4579 section: InventorySection::Worn,
4580 });
4581 for child in &item.contents {
4582 if self_hit || stack_matches_filter(child, filter) {
4583 push_inventory_rows_filtered(
4584 &mut rows,
4585 1,
4586 child,
4587 &from,
4588 item.item_instance_id,
4589 InventorySection::Worn,
4590 if self_hit { "" } else { filter },
4591 );
4592 }
4593 }
4594 }
4595 rows
4596 }
4597
4598 pub fn carried_worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4602 let mut rows = Vec::new();
4603 for (slot, item) in &self.worn {
4604 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4605 for child in &item.contents {
4606 push_inventory_rows_filtered(
4607 &mut rows,
4608 0,
4609 child,
4610 &from,
4611 item.item_instance_id,
4612 InventorySection::Person,
4613 filter,
4614 );
4615 }
4616 }
4617 rows
4618 }
4619
4620 pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
4622 let mut rows = self.worn_rows();
4623 rows.extend(self.person_rows());
4624 rows.into_iter().map(|r| (r.depth, r.stack)).collect()
4625 }
4626
4627 pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
4631 let (px, py) = self.player_position();
4632 let mut list: Vec<NearbyContainer> = self
4633 .placed_containers
4634 .iter()
4635 .filter(|c| self.placed_container_in_current_space(c))
4636 .filter_map(|c| {
4637 let distance_m = (c.x - px).hypot(c.y - py);
4638 if distance_m > CONTAINER_RANGE_M {
4639 return None;
4640 }
4641 let mut rows = Vec::new();
4642 let from = flatland_protocol::InventoryLocation::Placed {
4643 container_id: c.id.clone(),
4644 };
4645 rows.push(InventoryRow {
4646 depth: 0,
4647 stack: flatland_protocol::ItemStack {
4648 template_id: c.template_id.clone(),
4649 quantity: 1,
4650 item_instance_id: c.item_instance_id,
4651 props: Default::default(),
4652 status_bindings: Vec::new(),
4653 contents: Vec::new(),
4654 display_name: Some(c.display_name.clone()),
4655 category: Some("container".into()),
4656 capacity_volume: c.capacity_volume,
4657 worker_lodging_capacity: c.worker_lodging_capacity,
4658 ..Default::default()
4659 },
4660 from: from.clone(),
4661 from_parent_instance_id: None,
4662 is_equip_shell: false,
4663 is_chest_shell: true,
4664 section: InventorySection::Nearby,
4665 });
4666 if c.accessible {
4667 for child in &c.contents {
4668 push_inventory_rows(
4669 &mut rows,
4670 1,
4671 child,
4672 &from,
4673 c.item_instance_id,
4674 InventorySection::Nearby,
4675 );
4676 }
4677 }
4678 Some(NearbyContainer {
4679 view: c.clone(),
4680 distance_m,
4681 rows,
4682 })
4683 })
4684 .collect();
4685 list.sort_by(|a, b| {
4686 a.distance_m
4687 .partial_cmp(&b.distance_m)
4688 .unwrap_or(std::cmp::Ordering::Equal)
4689 });
4690 list
4691 }
4692
4693 pub fn nearest_placed_container(
4695 &self,
4696 max_dist: f32,
4697 ) -> Option<flatland_protocol::PlacedContainerView> {
4698 let (px, py) = self.player_position();
4699 self.placed_containers
4700 .iter()
4701 .filter(|c| self.placed_container_in_current_space(c))
4702 .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
4703 .min_by(|a, b| {
4704 let da = (a.x - px).hypot(a.y - py);
4705 let db = (b.x - px).hypot(b.y - py);
4706 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
4707 })
4708 .cloned()
4709 }
4710
4711 pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
4714 let filter = self.inventory_filter.as_str();
4715 match self.inventory_tab {
4716 InventoryTab::OnPerson => {
4717 let mut rows = self.carried_worn_rows_filtered(filter);
4718 rows.extend(self.person_rows_filtered(filter));
4719 rows
4720 }
4721 InventoryTab::Nearby => {
4722 let mut rows = Vec::new();
4723 for nc in self.nearby_containers() {
4724 if filter.is_empty() {
4725 rows.extend(nc.rows);
4726 continue;
4727 }
4728 let shell = nc.rows.first().cloned();
4729 let contents: Vec<_> = nc
4730 .rows
4731 .iter()
4732 .skip(1)
4733 .filter(|r| stack_matches_filter(&r.stack, filter))
4734 .cloned()
4735 .collect();
4736 let shell_hit = shell
4737 .as_ref()
4738 .map(|s| stack_matches_filter(&s.stack, filter))
4739 .unwrap_or(false);
4740 if shell_hit || !contents.is_empty() {
4741 if let Some(s) = shell {
4742 rows.push(s);
4743 }
4744 if shell_hit {
4745 rows.extend(nc.rows.into_iter().skip(1));
4746 } else {
4747 rows.extend(contents);
4748 }
4749 }
4750 }
4751 rows
4752 }
4753 }
4754 }
4755
4756 pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
4757 self.inventory_selectable_rows()
4758 .into_iter()
4759 .nth(self.inventory_menu_index)
4760 }
4761
4762 fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
4763 let cat = self
4764 .inventory_item_category(&row.stack.template_id)
4765 .unwrap_or("");
4766 if cat == "key" {
4767 self.key_inventory_label(&row.stack)
4768 } else {
4769 row.stack
4770 .display_name
4771 .clone()
4772 .unwrap_or_else(|| row.stack.template_id.clone())
4773 }
4774 }
4775
4776 fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
4778 let bindings =
4779 format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4780 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4781 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4782 let mode = Self::grant_mode(&row.stack);
4783 format!(" [grant {effect} · {mode} — e apply]")
4784 } else {
4785 String::new()
4786 };
4787 let qty = if row.stack.quantity > 1 {
4788 format!(" ×{}", row.stack.quantity)
4789 } else {
4790 String::new()
4791 };
4792 let worn_slot = if row.is_equip_shell {
4793 match row.from {
4794 flatland_protocol::InventoryLocation::Worn { slot } => {
4795 format!(" ({})", body_slot_label(slot))
4796 }
4797 _ => String::new(),
4798 }
4799 } else {
4800 String::new()
4801 };
4802 format!("{grant_hint}{bindings}{qty}{worn_slot}")
4803 }
4804
4805 fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
4806 (
4807 row.stack.template_id.clone(),
4808 self.inventory_row_base_label(row),
4809 self.inventory_row_visible_mod_signature(row),
4810 )
4811 }
4812
4813 fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
4815 let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
4816 for row in self.inventory_selectable_rows() {
4817 if row.stack.item_instance_id.is_none() {
4818 continue;
4819 }
4820 let key = self.inventory_row_instance_identity_key(&row);
4821 *counts.entry(key).or_default() += 1;
4822 }
4823 counts
4824 .into_iter()
4825 .filter(|(_, n)| *n > 1)
4826 .map(|(k, _)| k)
4827 .collect()
4828 }
4829
4830 fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
4831 let hex: String = id
4832 .as_simple()
4833 .to_string()
4834 .chars()
4835 .filter(|c| c.is_ascii_hexdigit())
4836 .collect();
4837 let short = if hex.len() >= 4 {
4838 &hex[hex.len() - 4..]
4839 } else {
4840 hex.as_str()
4841 };
4842 format!("Instance {id} (#{short})")
4843 }
4844
4845 pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
4847 let cat = self
4848 .inventory_item_category(&row.stack.template_id)
4849 .unwrap_or("");
4850 let label = self.inventory_row_base_label(row);
4851 let hint: String = if row.is_equip_shell {
4852 " [worn — Enter to unequip]".into()
4853 } else if row.is_chest_shell {
4854 let (locked, lodging_note) = match &row.from {
4855 flatland_protocol::InventoryLocation::Placed { container_id } => {
4856 let locked = self
4857 .placed_containers
4858 .iter()
4859 .find(|c| c.id == *container_id)
4860 .map(|c| c.locked)
4861 .unwrap_or(false);
4862 let lodging_note = self
4863 .lodging_occupancy_label(container_id)
4864 .map(|who| format!(" [lodging: {who}]"))
4865 .unwrap_or_default();
4866 (locked, lodging_note)
4867 }
4868 _ => (false, String::new()),
4869 };
4870 if locked {
4871 format!(" [locked — Enter pick up · l unlock]{lodging_note}")
4872 } else {
4873 format!(" [Enter pick up · l lock]{lodging_note}")
4874 }
4875 } else if cat == "key" {
4876 self.key_inventory_hint(&row.stack)
4877 } else {
4878 match cat {
4879 "weapon" => " [weapon]".into(),
4880 "container" => " [bag/chest/belt]".into(),
4881 "lodging" => " [worker lodging]".into(),
4882 "armor" => " [armor]".into(),
4883 _ => String::new(),
4884 }
4885 };
4886 let qty = if row.stack.quantity > 1 {
4887 format!(" ×{}", row.stack.quantity)
4888 } else {
4889 String::new()
4890 };
4891 let bindings =
4892 format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4893 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4894 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4895 let mode = Self::grant_mode(&row.stack);
4896 format!(" [grant {effect} · {mode} — e apply]")
4897 } else {
4898 String::new()
4899 };
4900 let mass = self.stack_mass(&row.stack);
4901 let mass_kg = (mass >= 0.05).then_some(mass);
4902 let mass_str = mass_kg.map(|m| format!(" {m:.1} kg")).unwrap_or_default();
4903 let volume = self.container_volume_stats(row);
4904 let vol_str = self.container_volume_label(row);
4905
4906 let mut title = label.clone();
4907 title.push_str(&qty);
4908 if row.is_equip_shell {
4909 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
4910 title.push_str(&format!(" ({})", body_slot_label(slot)));
4911 }
4912 }
4913
4914 InventoryRowView {
4915 depth: row.depth,
4916 text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
4917 title: format!("{title}{grant_hint}{bindings}"),
4918 mass_kg,
4919 volume,
4920 instance_tooltip: None,
4921 }
4922 }
4923
4924 fn push_browser_item(
4925 &self,
4926 lines: &mut Vec<InventoryBrowserLine>,
4927 row: &InventoryRow,
4928 global_idx: &mut usize,
4929 target: usize,
4930 highlight: bool,
4931 ambiguous_instance_keys: &HashSet<(String, String, String)>,
4932 ) {
4933 let mut view = self.format_inventory_row(row);
4934 if let Some(id) = row.stack.item_instance_id {
4935 let key = self.inventory_row_instance_identity_key(row);
4936 if ambiguous_instance_keys.contains(&key) {
4937 view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
4938 }
4939 }
4940 lines.push(InventoryBrowserLine::Item {
4941 selectable_index: *global_idx,
4942 selected: highlight && *global_idx == target,
4943 depth: view.depth,
4944 text: view.text,
4945 title: view.title,
4946 mass_kg: view.mass_kg,
4947 volume: view.volume,
4948 instance_tooltip: view.instance_tooltip,
4949 });
4950 *global_idx += 1;
4951 }
4952
4953 pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
4956 let mut lines = Vec::new();
4957 let target = self.inventory_menu_index;
4958 let highlight = !self.show_move_picker && !self.show_grant_picker;
4959 let filter = self.inventory_filter.as_str();
4960 let mut global_idx = 0usize;
4961 let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
4962
4963 match self.inventory_tab {
4964 InventoryTab::OnPerson => {
4965 lines.push(InventoryBrowserLine::Section("— In carried bags —".into()));
4966 let carried = self.carried_worn_rows_filtered(filter);
4967 if carried.is_empty() {
4968 lines.push(InventoryBrowserLine::Hint(
4969 " (no items in carried bags)".into(),
4970 ));
4971 } else {
4972 for row in &carried {
4973 self.push_browser_item(
4974 &mut lines,
4975 row,
4976 &mut global_idx,
4977 target,
4978 highlight,
4979 &ambiguous_instance_keys,
4980 );
4981 }
4982 }
4983
4984 lines.push(InventoryBrowserLine::Blank);
4985 lines.push(InventoryBrowserLine::Section(
4986 "— On you (loose, not worn) —".into(),
4987 ));
4988 let person = self.person_rows_filtered(filter);
4989 if person.is_empty() {
4990 lines.push(InventoryBrowserLine::Hint(" (empty)".into()));
4991 } else {
4992 let mut last_group: Option<&'static str> = None;
4993 for row in &person {
4994 if row.depth == 0 {
4995 let cat = row
4996 .stack
4997 .category
4998 .as_deref()
4999 .or_else(|| self.inventory_item_category(&row.stack.template_id))
5000 .unwrap_or("");
5001 let (group, _) = inventory_category_group(cat);
5002 if last_group != Some(group) {
5003 lines.push(InventoryBrowserLine::SlotLabel(format!(" {group}")));
5004 last_group = Some(group);
5005 }
5006 }
5007 self.push_browser_item(
5008 &mut lines,
5009 row,
5010 &mut global_idx,
5011 target,
5012 highlight,
5013 &ambiguous_instance_keys,
5014 );
5015 }
5016 }
5017 }
5018 InventoryTab::Nearby => {
5019 let nearby = self.nearby_containers();
5020 if nearby.is_empty() {
5021 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
5022 lines.push(InventoryBrowserLine::Hint(
5023 " (none within reach — walk up to a chest)".into(),
5024 ));
5025 lines.push(InventoryBrowserLine::Hint(
5026 " Select an on-person item, then m / Enter → move into chest.".into(),
5027 ));
5028 } else {
5029 let mut any_visible = false;
5030 for nc in &nearby {
5031 let shell = nc.rows.first();
5032 let contents: Vec<&InventoryRow> = if filter.is_empty() {
5033 nc.rows.iter().skip(1).collect()
5034 } else {
5035 let shell_hit = shell
5036 .map(|s| {
5037 let f = filter.to_ascii_lowercase();
5038 let name = s
5039 .stack
5040 .display_name
5041 .as_deref()
5042 .unwrap_or("")
5043 .to_ascii_lowercase();
5044 let tid = s.stack.template_id.to_ascii_lowercase();
5045 name.contains(&f) || tid.contains(&f)
5046 })
5047 .unwrap_or(false);
5048 if shell_hit {
5049 nc.rows.iter().skip(1).collect()
5050 } else {
5051 nc.rows
5052 .iter()
5053 .skip(1)
5054 .filter(|r| stack_matches_filter(&r.stack, filter))
5055 .collect()
5056 }
5057 };
5058 let shell_visible = filter.is_empty()
5059 || shell
5060 .map(|s| stack_matches_filter(&s.stack, filter))
5061 .unwrap_or(false)
5062 || !contents.is_empty();
5063 if !shell_visible && shell.is_some() {
5064 continue;
5065 }
5066 any_visible = true;
5067 lines.push(InventoryBrowserLine::Blank);
5068 let lock_note = if nc.view.locked && nc.view.accessible {
5069 " unlocked with your key"
5070 } else if nc.view.locked {
5071 " locked"
5072 } else {
5073 ""
5074 };
5075 lines.push(InventoryBrowserLine::Section(format!(
5076 "— {} ({:.0}m away){lock_note} —",
5077 nc.view.display_name, nc.distance_m
5078 )));
5079 if !nc.view.accessible {
5080 lines.push(InventoryBrowserLine::Hint(
5081 " locked — need the matching key (l to try)".into(),
5082 ));
5083 } else if nc.rows.is_empty() {
5084 lines.push(InventoryBrowserLine::Hint(
5085 " (empty — switch to On person, select an item, m to move in)"
5086 .into(),
5087 ));
5088 } else if let Some(shell_row) = shell {
5089 self.push_browser_item(
5090 &mut lines,
5091 shell_row,
5092 &mut global_idx,
5093 target,
5094 highlight,
5095 &ambiguous_instance_keys,
5096 );
5097 for row in contents {
5098 self.push_browser_item(
5099 &mut lines,
5100 row,
5101 &mut global_idx,
5102 target,
5103 highlight,
5104 &ambiguous_instance_keys,
5105 );
5106 }
5107 }
5108 }
5109 if !any_visible {
5110 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
5111 lines.push(InventoryBrowserLine::Hint(
5112 " (no matching items — clear filter with Esc)".into(),
5113 ));
5114 }
5115 }
5116 }
5117 }
5118 lines
5119 }
5120
5121 pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
5123 let mut opts = Vec::new();
5124 opts.push(MoveOption::action(
5125 "Relocate…",
5126 MoveOptionKind::RelocatePlaced {
5127 container_id: container_id.to_string(),
5128 },
5129 ));
5130 opts.push(MoveOption::action(
5131 "On your person (loose)",
5132 MoveOptionKind::PickupPlaced {
5133 container_id: container_id.to_string(),
5134 nest_location: flatland_protocol::InventoryLocation::Root,
5135 nest_parent_instance_id: None,
5136 },
5137 ));
5138 for (slot, item) in &self.worn {
5139 if item.category.as_deref() != Some("container") {
5140 continue;
5141 }
5142 if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
5143 continue;
5144 }
5145 let Some(parent_id) = item.item_instance_id else {
5146 continue;
5147 };
5148 let shell_name = item
5149 .display_name
5150 .clone()
5151 .unwrap_or_else(|| item.template_id.clone());
5152 let nest_location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
5153 opts.push(MoveOption {
5154 label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
5155 kind: MoveOptionKind::PickupPlaced {
5156 container_id: container_id.to_string(),
5157 nest_location: nest_location.clone(),
5158 nest_parent_instance_id: Some(parent_id),
5159 },
5160 volume: self.destination_volume_stats(&nest_location, Some(parent_id)),
5161 });
5162 self.append_chest_pickup_nested(
5164 &mut opts,
5165 container_id,
5166 nest_location,
5167 item,
5168 &format!("in {shell_name}"),
5169 );
5170 }
5171 opts.push(MoveOption::action("Cancel", MoveOptionKind::Cancel));
5172 opts
5173 }
5174
5175 fn append_chest_pickup_nested(
5176 &self,
5177 opts: &mut Vec<MoveOption>,
5178 container_id: &str,
5179 location: flatland_protocol::InventoryLocation,
5180 parent: &flatland_protocol::ItemStack,
5181 context: &str,
5182 ) {
5183 for child in &parent.contents {
5184 if child.category.as_deref() != Some("container") {
5185 continue;
5186 }
5187 if !Self::is_volume_container_stack(child) {
5188 continue;
5189 }
5190 if child.world_placeable == Some(true) {
5192 continue;
5193 }
5194 let Some(child_id) = child.item_instance_id else {
5195 continue;
5196 };
5197 let name = child
5198 .display_name
5199 .clone()
5200 .unwrap_or_else(|| child.template_id.clone());
5201 opts.push(MoveOption {
5202 label: format!("{name} ({context})"),
5203 kind: MoveOptionKind::PickupPlaced {
5204 container_id: container_id.to_string(),
5205 nest_location: location.clone(),
5206 nest_parent_instance_id: Some(child_id),
5207 },
5208 volume: self.destination_volume_stats(&location, Some(child_id)),
5209 });
5210 self.append_chest_pickup_nested(
5211 opts,
5212 container_id,
5213 location.clone(),
5214 child,
5215 &format!("in {name}"),
5216 );
5217 }
5218 }
5219
5220 pub fn move_destinations_for(
5222 &self,
5223 from: &flatland_protocol::InventoryLocation,
5224 from_parent_instance_id: Option<uuid::Uuid>,
5225 moving_instance_id: Option<uuid::Uuid>,
5226 moving_template_id: &str,
5227 ) -> Vec<MoveOption> {
5228 let mut opts = Vec::new();
5229 if *from != flatland_protocol::InventoryLocation::Root {
5230 opts.push(MoveOption::action(
5231 "On your person (loose)",
5232 MoveOptionKind::Move {
5233 location: flatland_protocol::InventoryLocation::Root,
5234 parent_instance_id: None,
5235 },
5236 ));
5237 }
5238 for (slot, item) in &self.worn {
5239 if item.category.as_deref() != Some("container") {
5240 continue;
5241 }
5242 let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
5243 let shell_name = item
5244 .display_name
5245 .clone()
5246 .unwrap_or_else(|| item.template_id.clone());
5247
5248 if *slot != BodySlot::Waist
5250 && item.item_instance_id != moving_instance_id
5251 && Self::is_volume_container_stack(item)
5252 {
5253 self.push_move_destination(
5254 &mut opts,
5255 format!("{shell_name} (worn {})", body_slot_label(*slot)),
5256 location.clone(),
5257 item.item_instance_id,
5258 from,
5259 from_parent_instance_id,
5260 );
5261 }
5262
5263 if *slot == BodySlot::Waist
5265 && Self::attaches_to_belt_loop(moving_template_id)
5266 && item.item_instance_id != moving_instance_id
5267 {
5268 self.push_move_destination(
5269 &mut opts,
5270 format!("{shell_name} (belt loop)"),
5271 location.clone(),
5272 item.item_instance_id,
5273 from,
5274 from_parent_instance_id,
5275 );
5276 }
5277
5278 let context = if *slot == BodySlot::Waist {
5279 format!("on {shell_name}")
5280 } else {
5281 format!("in {shell_name}")
5282 };
5283 self.append_nested_container_destinations(
5284 &mut opts,
5285 location,
5286 item,
5287 &context,
5288 from,
5289 from_parent_instance_id,
5290 moving_instance_id,
5291 );
5292 }
5293 for nc in self.nearby_containers() {
5294 if !nc.view.accessible {
5295 continue;
5296 }
5297 let location = flatland_protocol::InventoryLocation::Placed {
5298 container_id: nc.view.id.clone(),
5299 };
5300 self.push_move_destination(
5301 &mut opts,
5302 format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
5303 location,
5304 nc.view.item_instance_id,
5305 from,
5306 from_parent_instance_id,
5307 );
5308 }
5309 let allow_drop = moving_instance_id
5310 .map(|id| !self.hand_equipped_instance_ids().contains(&id))
5311 .unwrap_or(true)
5312 && moving_instance_id
5313 .and_then(|id| self.stack_for_instance(id))
5314 .map(|stack| {
5315 !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
5316 })
5317 .unwrap_or(
5318 moving_template_id != KEY_TEMPLATE
5319 && moving_template_id != PROPERTY_DEED_TEMPLATE,
5320 );
5321 if allow_drop {
5322 opts.push(MoveOption::action(
5323 "Drop on the ground",
5324 MoveOptionKind::Drop,
5325 ));
5326 }
5327 opts.push(MoveOption::action("Cancel", MoveOptionKind::Cancel));
5328 opts
5329 }
5330
5331 fn is_same_container_dest(
5332 dest_location: &flatland_protocol::InventoryLocation,
5333 dest_parent: Option<uuid::Uuid>,
5334 from: &flatland_protocol::InventoryLocation,
5335 from_parent: Option<uuid::Uuid>,
5336 ) -> bool {
5337 dest_location == from && dest_parent == from_parent
5338 }
5339
5340 fn push_move_destination(
5341 &self,
5342 opts: &mut Vec<MoveOption>,
5343 label: String,
5344 location: flatland_protocol::InventoryLocation,
5345 parent_instance_id: Option<uuid::Uuid>,
5346 from: &flatland_protocol::InventoryLocation,
5347 from_parent_instance_id: Option<uuid::Uuid>,
5348 ) {
5349 if Self::is_same_container_dest(
5350 &location,
5351 parent_instance_id,
5352 from,
5353 from_parent_instance_id,
5354 ) {
5355 return;
5356 }
5357 let volume = self.destination_volume_stats(&location, parent_instance_id);
5358 opts.push(MoveOption {
5359 label,
5360 kind: MoveOptionKind::Move {
5361 location,
5362 parent_instance_id,
5363 },
5364 volume,
5365 });
5366 }
5367
5368 fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
5369 stack.capacity_volume.is_some_and(|c| c > 0.0)
5370 }
5371
5372 fn attaches_to_belt_loop(template_id: &str) -> bool {
5373 matches!(template_id, "leather_pouch" | "dimensional_pouch")
5374 }
5375
5376 fn append_nested_container_destinations(
5377 &self,
5378 opts: &mut Vec<MoveOption>,
5379 location: flatland_protocol::InventoryLocation,
5380 container: &flatland_protocol::ItemStack,
5381 context: &str,
5382 from: &flatland_protocol::InventoryLocation,
5383 from_parent_instance_id: Option<uuid::Uuid>,
5384 moving_instance_id: Option<uuid::Uuid>,
5385 ) {
5386 for child in &container.contents {
5387 if Self::is_volume_container_stack(child)
5388 && child.item_instance_id != moving_instance_id
5389 {
5390 let name = child
5391 .display_name
5392 .clone()
5393 .unwrap_or_else(|| child.template_id.clone());
5394 self.push_move_destination(
5395 opts,
5396 format!("{name} ({context})"),
5397 location.clone(),
5398 child.item_instance_id,
5399 from,
5400 from_parent_instance_id,
5401 );
5402 }
5403 let nested_context = format!(
5404 "in {}",
5405 child.display_name.as_deref().unwrap_or(&child.template_id)
5406 );
5407 self.append_nested_container_destinations(
5408 opts,
5409 location.clone(),
5410 child,
5411 &nested_context,
5412 from,
5413 from_parent_instance_id,
5414 moving_instance_id,
5415 );
5416 }
5417 }
5418
5419 fn clamp_inventory_indices(&mut self) {
5420 let n = self.inventory_selectable_rows().len();
5421 self.inventory_menu_index = if n == 0 {
5422 0
5423 } else {
5424 self.inventory_menu_index.min(n - 1)
5425 };
5426 if let Some(picker) = &self.move_picker {
5427 let pn = picker.options.len();
5428 self.move_picker_index = if pn == 0 {
5429 0
5430 } else {
5431 self.move_picker_index.min(pn - 1)
5432 };
5433 }
5434 }
5435
5436 fn sync_interior_map_context(&mut self) {
5441 if self.effective_inside_building().is_none() {
5442 self.interior_map = None;
5443 if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
5444 self.z_platforms = platforms;
5445 self.z_transitions = transitions;
5446 }
5447 return;
5448 }
5449 self.sync_interior_z_bands();
5450 }
5451
5452 fn sync_interior_z_bands(&mut self) {
5454 if self.effective_inside_building().is_some() {
5455 if let Some(map) = &self.interior_map {
5456 if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
5457 if self.z_bands_outdoor_backup.is_none() {
5458 self.z_bands_outdoor_backup = Some((
5459 std::mem::take(&mut self.z_platforms),
5460 std::mem::take(&mut self.z_transitions),
5461 ));
5462 }
5463 self.z_platforms = map.z_platforms.clone();
5464 self.z_transitions = map.z_transitions.clone();
5465 }
5466 }
5467 }
5468 }
5469
5470 fn apply_snapshot_fields(
5471 &mut self,
5472 snapshot: &flatland_protocol::Snapshot,
5473 entity_id: EntityId,
5474 ) {
5475 self.tick = snapshot.tick;
5476 self.chunk_rev = snapshot.chunk_rev;
5477 self.content_rev = snapshot.content_rev;
5478 self.publish_rev = snapshot.publish_rev;
5479 self.resource_nodes = snapshot.resource_nodes.clone();
5480 self.replace_harvest_route_nodes(&snapshot.resource_nodes);
5481 self.ground_drops = snapshot.ground_drops.clone();
5482 self.placed_containers = snapshot.placed_containers.clone();
5483 self.world_x0 = snapshot.world_x0;
5484 self.world_y0 = snapshot.world_y0;
5485 self.world_width_m = snapshot.world_width_m;
5486 self.world_height_m = snapshot.world_height_m;
5487 self.world_clock = snapshot.world_clock;
5488 self.terrain_zones = snapshot.terrain_zones.clone();
5489 self.z_platforms = snapshot.z_platforms.clone();
5490 self.z_transitions = snapshot.z_transitions.clone();
5491 self.z_bands_outdoor_backup = None;
5493 self.buildings = snapshot.buildings.clone();
5494 self.doors = snapshot.doors.clone();
5495 self.interior_map = snapshot.interior_map.clone();
5496 self.npcs = snapshot.npcs.clone();
5497 self.blueprints = snapshot.blueprints.clone();
5498 self.building_materials = snapshot.building_materials.clone();
5499 self.sync_inventory_from_stacks(&snapshot.inventory);
5500 self.player = snapshot
5501 .entities
5502 .iter()
5503 .find(|e| e.id == entity_id)
5504 .cloned();
5505 self.entities = snapshot.entities.clone();
5506 self.quest_log = snapshot.quest_log.clone();
5507 self.apply_hired_workers(snapshot.hired_workers.clone());
5508 self.interactables = snapshot.interactables.clone();
5509 self.ledger = snapshot.ledger.clone();
5510 self.career = snapshot.career.clone();
5511 self.combat_fx = snapshot.combat_fx.clone();
5512 self.ground_hazards = snapshot.ground_hazards.clone();
5513 self.property_zones = snapshot.property_zones.clone();
5514 self.tax_zones = snapshot.tax_zones.clone();
5515 self.growth_zones = snapshot.growth_zones.clone();
5516 self.biome_zones = snapshot.biome_zones.clone();
5517 self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
5518 self.property_plots = snapshot.property_plots.clone();
5519 self.property_plot_settings = snapshot.property_plot_settings.clone();
5520 self.sync_item_catalog(&snapshot.item_catalog);
5521 if self.effective_inside_building().is_some() {
5524 self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
5525 }
5526 self.sync_interior_map_context();
5527 self.refresh_whisper_range();
5528 self.sync_gameplay_audio();
5529 }
5530
5531 fn replace_harvest_route_nodes(&mut self, incoming: &[flatland_protocol::ResourceNodeView]) {
5532 self.harvest_route_nodes = incoming
5533 .iter()
5534 .filter(|n| crate::is_harvest_route_node(n))
5535 .cloned()
5536 .collect();
5537 }
5538
5539 fn upsert_harvest_route_nodes(&mut self, incoming: &[flatland_protocol::ResourceNodeView]) {
5540 for node in incoming.iter().filter(|n| crate::is_harvest_route_node(n)) {
5541 if let Some(existing) = self
5542 .harvest_route_nodes
5543 .iter_mut()
5544 .find(|n| n.id == node.id)
5545 {
5546 *existing = node.clone();
5547 } else {
5548 self.harvest_route_nodes.push(node.clone());
5549 }
5550 }
5551 }
5552
5553 fn refresh_inventory_ui(&mut self) {
5557 if let Some(picker) = &self.move_picker {
5558 let instance_id = picker.item_instance_id;
5559 let still_exists = self
5560 .inventory_selectable_rows()
5561 .iter()
5562 .any(|r| r.stack.item_instance_id == Some(instance_id));
5563 if !still_exists {
5564 self.move_picker = None;
5565 self.show_move_picker = false;
5566 }
5567 }
5568 if let Some(picker) = &self.destroy_picker {
5569 let instance_id = picker.item_instance_id;
5570 let still_exists = self
5571 .inventory_selectable_rows()
5572 .iter()
5573 .any(|r| r.stack.item_instance_id == Some(instance_id));
5574 if !still_exists {
5575 self.destroy_picker = None;
5576 self.show_destroy_picker = false;
5577 self.destroy_confirm_pending = false;
5578 }
5579 }
5580 self.clamp_inventory_indices();
5581 }
5582
5583 fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
5589 let selected_id = self
5590 .hired_workers
5591 .get(self.workers_menu_index)
5592 .map(|w| w.instance_id.clone());
5593 let previous_worker_ids: HashSet<String> = self
5594 .hired_workers
5595 .iter()
5596 .map(|worker| worker.instance_id.clone())
5597 .collect();
5598 workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
5599 let now = Instant::now();
5600 let saw_new_worker = workers
5601 .iter()
5602 .any(|worker| !previous_worker_ids.contains(&worker.instance_id));
5603 for worker in &workers {
5604 let was_hit = self
5605 .hired_workers
5606 .iter()
5607 .find(|previous| previous.instance_id == worker.instance_id)
5608 .is_some_and(|previous| {
5609 matches!(worker.mode, flatland_protocol::WorkerModeView::Defender)
5610 && worker.vitals.health_pct + 0.01 < previous.vitals.health_pct
5611 });
5612 if was_hit {
5613 self.worker_health_ring_until
5614 .insert(worker.entity_id, now + WORKER_HEALTH_RING_HOLD);
5615 }
5616 }
5617 let worker_entity_ids: HashSet<EntityId> =
5618 workers.iter().map(|worker| worker.entity_id).collect();
5619 self.worker_health_ring_until
5620 .retain(|entity_id, _| worker_entity_ids.contains(entity_id));
5621 for w in &workers {
5622 let prev_err = self
5623 .hired_workers
5624 .iter()
5625 .find(|p| p.instance_id == w.instance_id)
5626 .and_then(|p| p.last_error.as_deref());
5627 let new_err = w.last_error.as_deref();
5628 if new_err != prev_err {
5629 if let Some(err) = new_err {
5630 if !worker_error_is_transient(err) {
5631 self.push_log(format!("Worker {}: {err}", w.label));
5632 }
5633 }
5634 }
5635 }
5636 let mut next_display = BTreeMap::new();
5637 let mut next_errors = BTreeMap::new();
5638 for w in &workers {
5639 let mut sticky = self
5640 .worker_step_display
5641 .remove(&w.instance_id)
5642 .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
5643 sticky.observe(&w.step_label, now);
5644 next_display.insert(w.instance_id.clone(), sticky);
5645
5646 let mut err_sticky = self
5647 .worker_error_display
5648 .remove(&w.instance_id)
5649 .unwrap_or_default();
5650 err_sticky.observe(w.last_error.as_deref(), now);
5651 if err_sticky.shown(now).is_some() {
5652 next_errors.insert(w.instance_id.clone(), err_sticky);
5653 }
5654 }
5655 self.worker_step_display = next_display;
5656 self.worker_error_display = next_errors;
5657 self.hired_workers = workers;
5658 if saw_new_worker {
5659 self.pending_worker_hire_since = None;
5660 }
5661 self.sync_worker_take_picker_from_hired();
5662 if let Some(id) = selected_id {
5663 if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
5664 self.workers_menu_index = idx;
5665 return;
5666 }
5667 }
5668 if self.workers_menu_index >= self.hired_workers.len() {
5669 self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
5670 }
5671 }
5672
5673 fn sync_worker_take_picker_from_hired(&mut self) {
5675 if !self.show_worker_take_picker {
5676 return;
5677 }
5678 let Some(picker) = self.worker_take_picker.clone() else {
5679 return;
5680 };
5681 let Some(worker) = self
5682 .hired_workers
5683 .iter()
5684 .find(|w| w.instance_id == picker.worker_instance_id)
5685 .cloned()
5686 else {
5687 self.show_worker_take_picker = false;
5688 self.worker_take_picker = None;
5689 self.worker_take_picker_index = 0;
5690 return;
5691 };
5692 let options: Vec<WorkerGiveOption> = worker
5693 .inventory
5694 .iter()
5695 .filter_map(|stack| {
5696 let item_instance_id = stack.item_instance_id?;
5697 let label = stack
5698 .display_name
5699 .clone()
5700 .unwrap_or_else(|| stack.template_id.clone());
5701 let label = if stack.quantity > 1 {
5702 format!("{label} ×{}", stack.quantity)
5703 } else {
5704 label
5705 };
5706 Some(WorkerGiveOption {
5707 item_instance_id,
5708 label,
5709 quantity: stack.quantity,
5710 template_id: stack.template_id.clone(),
5711 })
5712 })
5713 .collect();
5714 if options.is_empty() {
5715 self.show_worker_take_picker = false;
5716 self.worker_take_picker = None;
5717 self.worker_take_picker_index = 0;
5718 return;
5719 }
5720 let prev_id = picker
5721 .options
5722 .get(self.worker_take_picker_index)
5723 .map(|o| o.item_instance_id);
5724 let idx = prev_id
5725 .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
5726 .unwrap_or(0)
5727 .min(options.len().saturating_sub(1));
5728 let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
5729 let quantity = picker.quantity.clamp(1, max_qty);
5730 self.worker_take_picker_index = idx;
5731 self.worker_take_picker = Some(WorkerTakePicker {
5732 worker_instance_id: picker.worker_instance_id,
5733 worker_label: picker.worker_label,
5734 options,
5735 quantity,
5736 });
5737 }
5738
5739 pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
5741 self.worker_step_display
5742 .get(worker_instance_id)
5743 .map(|s| s.shown.as_str())
5744 .or_else(|| {
5745 self.hired_workers
5746 .iter()
5747 .find(|w| w.instance_id == worker_instance_id)
5748 .map(|w| w.step_label.as_str())
5749 })
5750 .unwrap_or("")
5751 }
5752
5753 pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
5755 let now = Instant::now();
5756 self.worker_error_display
5757 .get(worker_instance_id)
5758 .and_then(|s| s.shown(now))
5759 .or_else(|| {
5760 self.hired_workers
5761 .iter()
5762 .find(|w| w.instance_id == worker_instance_id)
5763 .and_then(|w| w.last_error.as_deref())
5764 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
5765 })
5766 .filter(|e| !worker_error_is_hud_noise(e))
5767 }
5768
5769 fn apply_combat_hud(&mut self, combat: &CombatHud) {
5770 self.in_combat = combat.in_combat;
5771 self.auto_attack = combat.auto_attack;
5772 self.combat_has_los = combat.has_los;
5773 self.attack_cd_ticks = combat.attack_cd_ticks;
5774 self.gcd_ticks = combat.gcd_ticks;
5775 self.weapon_ability_id = combat.ability_id.clone();
5776 self.mainhand_template_id = combat.mainhand_template_id.clone();
5777 self.mainhand_label = combat.mainhand_label.clone();
5778 self.mainhand_instance_id = combat.mainhand_instance_id;
5779 self.offhand_template_id = combat.offhand_template_id.clone();
5780 self.offhand_label = combat.offhand_label.clone();
5781 self.offhand_instance_id = combat.offhand_instance_id;
5782 self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
5783 1
5784 } else {
5785 combat.mainhand_hand_slots
5786 };
5787 self.defense = combat.defense.clone();
5788 self.worn = combat.worn.iter().cloned().collect();
5789 self.carry_mass = combat.carry_mass;
5790 self.carry_mass_max = combat.carry_mass_max;
5791 self.encumbrance = combat.encumbrance;
5792 self.move_speed_mps = combat.move_speed_mps;
5793 self.move_speed_mult = combat.move_speed_mult;
5794 self.cast_progress = combat.cast.clone();
5795 self.timed_channel = combat.timed_channel.clone();
5796 if self.active_craft_channel().is_none() {
5797 self.craft_channel_blueprint_id = None;
5798 }
5799 self.plot_build_offer = combat.plot_build.clone();
5800 self.ability_cooldowns = combat.ability_cooldowns.clone();
5801 self.blocking_active = combat.blocking_active;
5802 self.max_target_slots = combat.max_target_slots.max(1);
5803 self.combat_slots = combat.slots.clone();
5804 self.rotation_presets = combat.rotation_presets.clone();
5805 self.known_abilities = combat.known_abilities.clone();
5806 self.ability_meta = combat
5807 .ability_meta
5808 .iter()
5809 .cloned()
5810 .map(|meta| (meta.id.clone(), meta))
5811 .collect();
5812 self.ability_mastery = combat
5813 .ability_mastery
5814 .iter()
5815 .cloned()
5816 .map(|row| (row.ability_id.clone(), row))
5817 .collect();
5818 self.hotbar = combat.hotbar.clone();
5819 self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
5820 self.keychain_stacks = combat.keychain.clone();
5821 self.whisper_pouch_stacks = combat.whisper_pouch.clone();
5822 self.combat_target_detail = combat.target.clone();
5823 self.statuses = combat.statuses.clone();
5824 self.combat_target = combat.target_entity_id;
5825 if combat.progression_xp_base > 0.0 {
5826 self.progression_curve = Some(flatland_protocol::ProgressionCurve {
5827 baseline_display: combat.progression_baseline,
5828 xp_base: combat.progression_xp_base,
5829 xp_growth: combat.progression_xp_growth,
5830 });
5831 }
5832 if let Some(xp) = &combat.progression_xp {
5833 if let Some(player) = &mut self.player {
5834 player.progression_xp = Some(xp.clone());
5835 if let Some(attrs) = combat.attributes {
5836 player.attributes = Some(attrs);
5837 }
5838 if let Some(skills) = &combat.skills {
5839 player.skills = Some(skills.clone());
5840 }
5841 }
5842 }
5843 if let Some(label) = &combat.target_label {
5844 self.combat_target_label = Some(label.clone());
5845 } else if let Some(id) = combat.target_entity_id {
5846 self.combat_target_label = self
5847 .entities
5848 .iter()
5849 .find(|e| e.id == id)
5850 .map(|e| e.label.clone())
5851 .or_else(|| self.combat_target_label.clone());
5852 }
5853 self.refresh_inventory_ui();
5854 }
5855
5856 pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
5858 self.combat_slots
5859 .iter()
5860 .find(|s| s.slot_index == slot)
5861 .and_then(|s| s.target_entity_id)
5862 .or_else(|| if slot == 1 { self.combat_target } else { None })
5863 }
5864
5865 pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
5867 self.ability_meta
5868 .get(ability_id)
5869 .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
5870 .unwrap_or(self.ground_target.is_some())
5873 }
5874
5875 pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
5877 self.ability_meta
5878 .get(ability_id)
5879 .map(|meta| meta.aim_mode == "ground")
5880 .unwrap_or(false)
5881 }
5882
5883 pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
5886 self.ability_meta
5887 .get(ability_id)
5888 .map(|meta| meta.auto_rotation_eligible)
5889 .unwrap_or(true)
5890 }
5891
5892 pub fn set_ground_target(&mut self, x: f32, y: f32) {
5894 self.ground_target = Some((x, y, 0.0));
5895 }
5896
5897 pub fn clear_ground_target(&mut self) {
5899 self.ground_target = None;
5900 }
5901
5902 pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
5905 if !(1..=9).contains(&slot_1_to_9) {
5906 return None;
5907 }
5908 self.hotbar
5909 .get((slot_1_to_9 - 1) as usize)
5910 .and_then(|a| a.as_deref())
5911 .filter(|id| !id.is_empty())
5912 }
5913
5914 pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
5916 let binding = self.hotbar_ability(slot_1_to_9)?;
5917 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
5918 let name = self
5919 .inventory_hints
5920 .get(template_id)
5921 .map(|h| h.display_name.as_str())
5922 .unwrap_or(template_id);
5923 let qty = self.inventory.get(template_id).copied().unwrap_or(0);
5924 Some(format!("{name}×{qty}"))
5925 } else {
5926 Some(binding.to_string())
5927 }
5928 }
5929
5930 pub fn loadout_ability_choices(&self) -> Vec<String> {
5932 let mut out = self.known_abilities.clone();
5933 let weapon = self.weapon_ability_id.trim();
5934 if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
5935 out.push(weapon.to_string());
5936 }
5937 out
5938 }
5939
5940 pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
5942 let mut out = Vec::new();
5943 for ability in self.loadout_ability_choices() {
5944 let meta = if ability == self.weapon_ability_id {
5945 Some("weapon".into())
5946 } else {
5947 None
5948 };
5949 out.push(LoadoutHotbarChoice {
5950 binding: ability.clone(),
5951 label: ability,
5952 meta,
5953 });
5954 }
5955 let mut consumables: Vec<(String, String, u32)> = Vec::new();
5956 for stack in &self.inventory_stacks {
5957 if Self::stack_is_item_grant(stack) {
5958 continue;
5959 }
5960 if Self::stack_is_blueprint_scroll(stack) {
5961 continue;
5962 }
5963 if self.inventory_item_category(&stack.template_id) != Some("consumable")
5964 && !Self::stack_is_serving(stack)
5965 {
5966 continue;
5967 }
5968 let qty = stack.quantity.max(1);
5969 if let Some((_, _, existing)) = consumables
5970 .iter_mut()
5971 .find(|(id, _, _)| id == &stack.template_id)
5972 {
5973 *existing = existing.saturating_add(qty);
5974 } else {
5975 let label = stack
5976 .display_name
5977 .clone()
5978 .or_else(|| {
5979 self.inventory_hints
5980 .get(&stack.template_id)
5981 .map(|h| h.display_name.clone())
5982 })
5983 .unwrap_or_else(|| stack.template_id.clone());
5984 consumables.push((stack.template_id.clone(), label, qty));
5985 }
5986 }
5987 consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
5988 for (template_id, label, qty) in consumables {
5989 out.push(LoadoutHotbarChoice {
5990 binding: flatland_protocol::hotbar_consumable_binding(&template_id),
5991 label: format!("{label} ×{qty}"),
5992 meta: Some("use".into()),
5993 });
5994 }
5995 out
5996 }
5997
5998 pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
6000 self.combat_candidates()
6001 }
6002
6003 pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
6005 let (px, py) = self.player_position();
6006 let dist = |id: EntityId| {
6007 self.entities
6008 .iter()
6009 .find(|e| e.id == id)
6010 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
6011 .unwrap_or(f32::MAX)
6012 };
6013
6014 let mut allies = Vec::new();
6015 if let Some(me) = self.player.as_ref() {
6017 let alive = me
6018 .vitals
6019 .as_ref()
6020 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
6021 .unwrap_or(true);
6022 if alive {
6023 allies.push((self.entity_id, "Yourself".into()));
6024 }
6025 }
6026 for entity in &self.entities {
6027 if entity.id == self.entity_id {
6028 continue;
6029 }
6030 if entity.vitals.is_some() {
6031 let alive = entity
6032 .vitals
6033 .as_ref()
6034 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
6035 .unwrap_or(true);
6036 if alive {
6037 allies.push((entity.id, entity.label.clone()));
6038 }
6039 }
6040 }
6041 allies.sort_by(|(a, _), (b, _)| {
6042 if *a == self.entity_id {
6043 return std::cmp::Ordering::Less;
6044 }
6045 if *b == self.entity_id {
6046 return std::cmp::Ordering::Greater;
6047 }
6048 dist(*a)
6049 .partial_cmp(&dist(*b))
6050 .unwrap_or(std::cmp::Ordering::Equal)
6051 });
6052
6053 let mut monsters = self.combat_candidates();
6054 monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
6055 allies.into_iter().chain(monsters).collect()
6056 }
6057
6058 fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
6059 match slot_index {
6060 2 => self.t2_candidates(),
6061 _ => self.t1_candidates(),
6062 }
6063 }
6064
6065 pub fn pick_combat_target_at(
6067 &self,
6068 wx: f32,
6069 wy: f32,
6070 slot_index: u8,
6071 radius_m: f32,
6072 ) -> Option<(EntityId, String)> {
6073 let mut best: Option<(f32, EntityId, String)> = None;
6074 for (id, label) in self.candidates_for_slot(slot_index) {
6075 let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
6076 if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
6078 let d = distance(wx, wy, npc.x, npc.y);
6079 if d <= radius_m {
6080 best = match best {
6081 Some((bd, _, _)) if bd <= d => best,
6082 _ => Some((d, id, label)),
6083 };
6084 }
6085 }
6086 continue;
6087 };
6088 let d = distance(
6089 wx,
6090 wy,
6091 entity.transform.position.x,
6092 entity.transform.position.y,
6093 );
6094 if d <= radius_m {
6095 best = match best {
6096 Some((bd, _, _)) if bd <= d => best,
6097 _ => Some((d, id, label)),
6098 };
6099 }
6100 }
6101 best.map(|(_, id, label)| (id, label))
6102 }
6103
6104 pub(crate) fn restore_from_welcome(
6106 &mut self,
6107 session_id: SessionId,
6108 entity_id: EntityId,
6109 snapshot: &flatland_protocol::Snapshot,
6110 ) {
6111 self.clear_harvest_state();
6112 self.disconnect_reason = None;
6113 self.show_stats = false;
6114 self.show_craft_menu = false;
6115 self.show_shop_menu = false;
6116 self.shop_catalog = None;
6117 self.show_inventory_menu = false;
6118 self.session_id = session_id;
6119 self.entity_id = entity_id;
6120 self.connected = true;
6121 self.apply_snapshot_fields(snapshot, entity_id);
6122 if let Some(combat) = &snapshot.combat {
6123 self.apply_combat_hud(combat);
6124 let stacks = self.inventory_stacks.clone();
6125 self.sync_inventory_from_stacks(&stacks);
6126 }
6127 }
6128
6129 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
6130 self.tick = delta.tick;
6131 self.world_clock = delta.world_clock;
6132
6133 if delta.entities.is_empty() {
6135 self.ground_drops = delta.ground_drops.clone();
6136 self.combat_fx = delta.combat_fx.clone();
6137 self.ground_hazards = delta.ground_hazards.clone();
6138 self.property_plots = delta.property_plots.clone();
6139 self.apply_terrain_overlays(&delta.terrain_overlays);
6140 if let Some(combat) = &delta.combat {
6141 self.apply_combat_hud(combat);
6142 let stacks = self.inventory_stacks.clone();
6143 self.sync_inventory_from_stacks(&stacks);
6144 }
6145 self.refresh_whisper_range();
6147 self.sync_gameplay_audio();
6148 return;
6149 }
6150 if !delta.buildings.is_empty() {
6151 self.buildings = delta.buildings.clone();
6152 }
6153 if !delta.blueprints.is_empty() {
6154 self.blueprints = delta.blueprints.clone();
6155 }
6156 if !delta.building_materials.is_empty() {
6157 self.building_materials = delta.building_materials.clone();
6158 }
6159 self.sync_inventory_from_stacks(&delta.inventory);
6160
6161 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
6162 self.player = Some(updated.clone());
6163 }
6164 self.entities = delta.entities.clone();
6165 if self.player.is_none() {
6166 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
6167 }
6168
6169 self.sync_interior_map_context();
6170
6171 if !delta.resource_nodes.is_empty() {
6175 self.resource_nodes = delta.resource_nodes.clone();
6176 self.upsert_harvest_route_nodes(&delta.resource_nodes);
6177 } else if delta.interior_map.is_some() || self.effective_inside_building().is_some() {
6178 self.resource_nodes = delta.resource_nodes.clone();
6179 }
6180 self.ground_drops = delta.ground_drops.clone();
6181 self.placed_containers = delta.placed_containers.clone();
6183 if !delta.doors.is_empty() {
6184 self.doors = delta.doors.clone();
6185 }
6186 if self.effective_inside_building().is_some() {
6187 if let Some(map) = &delta.interior_map {
6188 self.interior_map = Some(map.clone());
6189 }
6190 } else {
6191 self.interior_map = None;
6192 }
6193 self.sync_interior_z_bands();
6194 self.npcs = delta.npcs.clone();
6196 if !delta.quest_log.is_empty() {
6197 self.quest_log = delta.quest_log.clone();
6198 }
6199 self.apply_hired_workers(delta.hired_workers.clone());
6200 if !delta.interactables.is_empty() {
6201 self.interactables = delta.interactables.clone();
6202 }
6203 if delta.ledger.is_some() {
6204 self.ledger = delta.ledger.clone();
6205 }
6206 if delta.career.is_some() {
6207 self.career = delta.career.clone();
6208 }
6209 self.combat_fx = delta.combat_fx.clone();
6210 self.ground_hazards = delta.ground_hazards.clone();
6211 if !delta.property_plots.is_empty() {
6213 self.property_plots = delta.property_plots.clone();
6214 }
6215 self.apply_terrain_overlays(&delta.terrain_overlays);
6216 if let Some(combat) = &delta.combat {
6217 self.apply_combat_hud(combat);
6218 let stacks = self.inventory_stacks.clone();
6219 self.sync_inventory_from_stacks(&stacks);
6220 } else {
6221 self.refresh_inventory_ui();
6222 }
6223 self.refresh_whisper_range();
6224 self.sync_gameplay_audio();
6225 }
6226
6227 fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
6230 self.terrain_zones.retain(|z| !z.id.starts_with("rt:"));
6231 self.terrain_zones.extend(overlays.iter().cloned());
6232 }
6233
6234 fn refresh_whisper_range(&mut self) {
6237 let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
6238 return;
6239 };
6240 let (px, py) = self.player_position();
6241 let in_range = self.entities.iter().any(|e| {
6242 e.id == peer
6243 && distance(px, py, e.transform.position.x, e.transform.position.y)
6244 <= INTERACTION_RADIUS_M
6245 });
6246 if !in_range {
6247 self.social_chat.cancel_whisper_out_of_range();
6248 }
6249 }
6250
6251 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
6253 let (px, py) = self.player_position();
6254 let mut out = Vec::new();
6255 for npc in &self.npcs {
6256 let Some(eid) = npc.entity_id else {
6257 continue;
6258 };
6259 let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
6260 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
6261 if alive && has_hp {
6262 out.push((eid, npc.label.clone()));
6263 }
6264 }
6265 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
6266 let dist = |id: EntityId| {
6267 self.entities
6268 .iter()
6269 .find(|e| e.id == id)
6270 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
6271 .unwrap_or(f32::MAX)
6272 };
6273 dist(*a_id)
6274 .partial_cmp(&dist(*b_id))
6275 .unwrap_or(std::cmp::Ordering::Equal)
6276 .then_with(|| a_label.cmp(b_label))
6277 .then_with(|| a_id.cmp(b_id))
6278 });
6279 out
6280 }
6281
6282 pub fn refresh_combat_target_label(&mut self) {
6283 let Some(id) = self.combat_target else {
6284 return;
6285 };
6286 if let Some((_, label)) = self
6287 .combat_candidates()
6288 .into_iter()
6289 .find(|(eid, _)| *eid == id)
6290 {
6291 self.combat_target_label = Some(label);
6292 } else if let Some(label) = self
6293 .entities
6294 .iter()
6295 .find(|e| e.id == id)
6296 .map(|e| e.label.clone())
6297 {
6298 self.combat_target_label = Some(label);
6299 }
6300 }
6301
6302 pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
6303 self.quest_log
6304 .iter()
6305 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
6306 .collect()
6307 }
6308
6309 pub fn has_worker_lodging(&self) -> bool {
6311 self.free_worker_lodging_slots() > 0
6312 }
6313
6314 pub fn free_worker_lodging_slots(&self) -> i64 {
6316 let slots: u32 = self
6317 .placed_containers
6318 .iter()
6319 .filter(|c| match (self.character_id, c.owner_character_id) {
6320 (Some(me), Some(owner)) => me == owner,
6321 (Some(_), None) => false,
6322 (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
6323 })
6324 .map(|c| c.worker_lodging_capacity.unwrap_or(0))
6325 .sum();
6326 let used = self.hired_workers.len() as u32;
6327 slots as i64 - used as i64
6328 }
6329
6330 pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
6332 let mut names: Vec<String> = self
6333 .hired_workers
6334 .iter()
6335 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
6336 .map(|w| w.label.clone())
6337 .collect();
6338 names.sort();
6339 names
6340 }
6341
6342 pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
6344 let is_lodging = self
6345 .placed_containers
6346 .iter()
6347 .find(|c| c.id == container_id)
6348 .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
6349 if !is_lodging {
6350 return None;
6351 }
6352 let names = self.lodging_occupant_labels(container_id);
6353 Some(if names.is_empty() {
6354 "vacant".into()
6355 } else {
6356 names.join(", ")
6357 })
6358 }
6359
6360 pub fn lodging_is_occupied(&self, container_id: &str) -> bool {
6362 matches!(
6363 self.lodging_occupancy_label(container_id),
6364 Some(label) if label != "vacant"
6365 )
6366 }
6367
6368 pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
6369 self.quest_log
6370 .iter()
6371 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
6372 .or_else(|| {
6373 self.quest_log
6374 .iter()
6375 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
6376 })
6377 }
6378
6379 pub fn nearby_lockable_door(&self) -> bool {
6381 let (px, py) = self.player_position();
6382 self.doors
6383 .iter()
6384 .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M)
6385 }
6386
6387 pub fn nearby_open_player_door(&self) -> bool {
6389 if self.effective_inside_building().is_some() {
6390 return false;
6391 }
6392 let (px, py) = self.player_position();
6393 self.doors.iter().any(|d| {
6394 if !d.open || d.locked {
6395 return false;
6396 }
6397 let player_house = self
6398 .buildings
6399 .iter()
6400 .find(|b| b.id == d.building_id)
6401 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6402 player_house && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M
6403 })
6404 }
6405
6406 pub fn nearby_player_exit_door(&self) -> bool {
6408 let Some(bid) = self.effective_inside_building() else {
6409 return false;
6410 };
6411 let (px, py) = self.player_position();
6412 self.doors.iter().any(|d| {
6413 if d.building_id != bid || d.portal.is_none() {
6414 return false;
6415 }
6416 let player_house = self
6417 .buildings
6418 .iter()
6419 .find(|b| b.id == d.building_id)
6420 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6421 player_house && (d.x - px).hypot(d.y - py) <= 1.5
6422 })
6423 }
6424
6425 pub fn nearest_interact_target(&self) -> Option<String> {
6427 let (px, py) = self.player_position();
6428 let inside = self.effective_inside_building();
6429
6430 #[derive(Clone, Copy, PartialEq, Eq)]
6431 enum Kind {
6432 Player,
6433 Npc,
6434 HiredWorker,
6435 QuestBoard,
6436 ExitDoor,
6437 EnterDoor,
6438 }
6439
6440 fn kind_class(kind: Kind) -> u8 {
6441 match kind {
6442 Kind::EnterDoor => 0,
6443 Kind::QuestBoard => 1,
6444 Kind::Player | Kind::Npc => 2,
6445 Kind::ExitDoor => 3,
6446 Kind::HiredWorker => 4,
6447 }
6448 }
6449
6450 fn kind_priority(kind: Kind) -> u8 {
6451 match kind {
6452 Kind::EnterDoor => 0,
6453 Kind::QuestBoard => 1,
6454 Kind::Player | Kind::Npc => 2,
6455 Kind::ExitDoor => 3,
6456 Kind::HiredWorker => 4,
6457 }
6458 }
6459
6460 let mut best: Option<(f32, Kind, String)> = None;
6461
6462 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
6463 if dist > max {
6464 return;
6465 }
6466 let replace = match best {
6467 None => true,
6468 Some((_, bk, _)) if kind_class(kind) < kind_class(bk) => true,
6469 Some((bd, bk, _)) if kind_class(kind) == kind_class(bk) && dist < bd - 0.05 => true,
6470 Some((bd, bk, _))
6471 if kind_class(kind) == kind_class(bk) && (dist - bd).abs() <= 0.05 =>
6472 {
6473 kind_priority(kind) < kind_priority(bk)
6474 }
6475 _ => false,
6476 };
6477 if replace {
6478 best = Some((dist, kind, id));
6479 }
6480 };
6481
6482 for npc in &self.npcs {
6483 consider(
6484 distance(px, py, npc.x, npc.y),
6485 INTERACTION_RADIUS_M,
6486 Kind::Npc,
6487 npc.id.clone(),
6488 );
6489 }
6490
6491 for worker in &self.hired_workers {
6492 consider(
6493 distance(px, py, worker.x, worker.y),
6494 INTERACTION_RADIUS_M,
6495 Kind::HiredWorker,
6496 worker.instance_id.clone(),
6497 );
6498 }
6499
6500 for entity in &self.entities {
6501 if entity.id == self.entity_id
6502 || entity.vitals.is_none()
6503 || entity.label.trim().is_empty()
6504 {
6505 continue;
6506 }
6507 if self.hired_workers.iter().any(|w| w.entity_id == entity.id) {
6509 continue;
6510 }
6511 consider(
6512 distance(
6513 px,
6514 py,
6515 entity.transform.position.x,
6516 entity.transform.position.y,
6517 ),
6518 INTERACTION_RADIUS_M,
6519 Kind::Player,
6520 entity.id.to_string(),
6521 );
6522 }
6523
6524 for door in &self.doors {
6525 if let Some(ref bid) = inside {
6526 if door.building_id != *bid {
6527 continue;
6528 }
6529 let is_exit = door.portal.is_some();
6530 let max = if is_exit {
6531 INTERACTION_RADIUS_M
6532 } else {
6533 DOOR_INTERACTION_RADIUS_M
6534 };
6535 let kind = if is_exit {
6536 Kind::ExitDoor
6537 } else {
6538 Kind::EnterDoor
6539 };
6540 consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
6541 continue;
6542 }
6543 consider(
6544 distance(px, py, door.x, door.y),
6545 DOOR_INTERACTION_RADIUS_M,
6546 Kind::EnterDoor,
6547 door.id.clone(),
6548 );
6549 }
6550
6551 if inside.is_none() {
6552 for inter in &self.interactables {
6553 if inter.kind == "quest_board" {
6554 consider(
6555 distance(px, py, inter.x, inter.y),
6556 QUEST_BOARD_INTERACTION_RADIUS_M,
6557 Kind::QuestBoard,
6558 inter.id.clone(),
6559 );
6560 }
6561 }
6562 }
6563
6564 best.map(|(_, _, id)| id)
6565 }
6566
6567 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
6569 if self.effective_inside_building().is_some() {
6570 return None;
6571 }
6572 let (px, py) = self.player_position();
6573 self.interactables
6574 .iter()
6575 .filter(|i| i.kind == "quest_board")
6576 .map(|i| {
6577 let label = if i.label.is_empty() {
6578 "Quest board".to_string()
6579 } else {
6580 i.label.clone()
6581 };
6582 (label, distance(px, py, i.x, i.y))
6583 })
6584 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
6585 }
6586
6587 pub fn template_display_name(&self, template_id: &str) -> String {
6589 if let Some(name) = self
6590 .inventory_hints
6591 .get(template_id)
6592 .map(|h| h.display_name.clone())
6593 .filter(|n| !n.is_empty())
6594 {
6595 return name;
6596 }
6597 if let Some(entry) = self.item_catalog.get(template_id) {
6598 if !entry.display_name.trim().is_empty() {
6599 return entry.display_name.clone();
6600 }
6601 }
6602 humanize_template_id(template_id)
6603 }
6604
6605 pub fn worker_route_stop_summary(
6607 &self,
6608 stop: &crate::worker_route_editor::WorkerRouteStop,
6609 ) -> String {
6610 stop.summary_resolved(
6611 |id| {
6612 self.placed_containers
6613 .iter()
6614 .find(|c| c.id == id)
6615 .map(|c| c.display_name.clone())
6616 .filter(|n| !n.trim().is_empty())
6617 .unwrap_or_else(|| "storage".to_string())
6618 },
6619 |id| {
6620 self.npcs
6621 .iter()
6622 .find(|n| n.id == id)
6623 .map(|n| n.label.clone())
6624 .filter(|s| !s.trim().is_empty())
6625 .unwrap_or_else(|| "merchant".to_string())
6626 },
6627 |id| {
6628 self.harvest_route_nodes
6629 .iter()
6630 .chain(self.resource_nodes.iter())
6631 .find(|n| n.id == id)
6632 .map(resource_node_route_label)
6633 .unwrap_or_else(|| resource_node_route_label_parts(id, "", ""))
6634 },
6635 |id| plot_stop_label(&self.property_plots, *id),
6636 |t| self.template_display_name(t),
6637 )
6638 }
6639
6640 pub fn catalog_entry(&self, template_id: &str) -> Option<&ItemCatalogEntryView> {
6641 self.item_catalog.get(template_id)
6642 }
6643
6644 pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
6646 if !display_name.is_empty() {
6647 display_name.to_string()
6648 } else {
6649 self.template_display_name(template_id)
6650 }
6651 }
6652
6653 pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
6654 self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
6655 }
6656
6657 pub fn blueprint_ingredient_label(
6658 &self,
6659 input: &flatland_protocol::BlueprintIngredientView,
6660 ) -> String {
6661 self.blueprint_item_label(&input.template_id, &input.display_name)
6662 }
6663
6664 pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
6665 self.blueprint_item_label(&tool.item, &tool.display_name)
6666 }
6667
6668 pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
6670 use crate::worker_route_editor::{
6671 node_candidates, node_candidates_stable, route_editor_lodging_anchor,
6672 };
6673 let nodes = if self.harvest_route_nodes.is_empty() {
6674 &self.resource_nodes
6675 } else {
6676 &self.harvest_route_nodes
6677 };
6678 let lodging = self
6679 .worker_route_editor
6680 .as_ref()
6681 .and_then(|ed| ed.lodging_container_id.as_deref());
6682 match route_editor_lodging_anchor(lodging, &self.placed_containers) {
6683 Some((ax, ay)) => node_candidates(nodes, ax, ay),
6684 None => node_candidates_stable(nodes),
6685 }
6686 }
6687
6688 pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
6689 if dist_m.is_nan() {
6690 return "—".into();
6691 }
6692 let from_bed = self
6693 .worker_route_editor
6694 .as_ref()
6695 .and_then(|ed| ed.lodging_container_id.as_deref())
6696 .and_then(|id| {
6697 self.placed_containers
6698 .iter()
6699 .find(|c| c.id == id)
6700 .map(|c| c.display_name.clone())
6701 });
6702 match from_bed {
6703 Some(bed) => format!("{dist_m:.0}m from {bed}"),
6704 None => format!("{dist_m:.0}m"),
6705 }
6706 }
6707
6708 pub fn placed_container_public_label(
6710 &self,
6711 c: &flatland_protocol::PlacedContainerView,
6712 ) -> String {
6713 let is_owner = match (self.character_id, c.owner_character_id) {
6714 (Some(me), Some(owner)) => me == owner,
6715 _ => false,
6716 };
6717 if is_owner {
6718 c.display_name.clone()
6719 } else {
6720 self.template_display_name(&c.template_id)
6721 }
6722 }
6723
6724 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
6726 let mut out = Vec::new();
6727 for stack in &self.inventory_stacks {
6728 if stack.template_id == KEY_TEMPLATE {
6729 out.push(KeychainEntry {
6730 stack: stack.clone(),
6731 stowed: false,
6732 });
6733 }
6734 }
6735 for stack in &self.keychain_stacks {
6736 if stack.template_id == KEY_TEMPLATE {
6737 out.push(KeychainEntry {
6738 stack: stack.clone(),
6739 stowed: true,
6740 });
6741 }
6742 }
6743 out
6744 }
6745
6746 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
6748 if stack.template_id != KEY_TEMPLATE {
6749 return None;
6750 }
6751 if let Some(name) = stack
6752 .props
6753 .get(PROP_OPENS_CONTAINER_NAME)
6754 .filter(|n| !n.is_empty())
6755 {
6756 return Some(name.clone());
6757 }
6758 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
6759 self.container_name_for_lock_id(opens)
6760 }
6761
6762 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
6764 if stack.template_id == KEY_TEMPLATE {
6765 self.template_display_name(KEY_TEMPLATE)
6766 } else {
6767 stack
6768 .display_name
6769 .clone()
6770 .unwrap_or_else(|| stack.template_id.clone())
6771 }
6772 }
6773
6774 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
6776 if stack.template_id != KEY_TEMPLATE {
6777 return String::new();
6778 }
6779 match self.key_pair_chest_label(stack) {
6780 Some(chest) if self.key_drop_blocked(stack) => {
6781 format!(" [key for {chest} — can't drop while locked]")
6782 }
6783 Some(chest) => format!(" [key for {chest}]"),
6784 None => " [key — unpaired]".into(),
6785 }
6786 }
6787
6788 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
6790 for c in &self.placed_containers {
6791 if c.lock_id.as_deref() == Some(lock) {
6792 return Some(c.display_name.clone());
6793 }
6794 }
6795 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
6796 self.worn
6797 .values()
6798 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
6799 })
6800 }
6801
6802 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
6804 if stack.template_id != KEY_TEMPLATE {
6805 return false;
6806 }
6807 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
6808 return false;
6809 };
6810 for c in &self.placed_containers {
6811 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
6812 return true;
6813 }
6814 }
6815 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
6816 return true;
6817 }
6818 self.worn
6819 .values()
6820 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
6821 }
6822
6823 pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
6825 stack.template_id == PROPERTY_DEED_TEMPLATE
6826 }
6827
6828 pub fn is_property_deed_template(template_id: &str) -> bool {
6829 template_id == PROPERTY_DEED_TEMPLATE
6830 }
6831
6832 pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
6833 stack
6834 .props
6835 .get("plot_id")
6836 .and_then(|s| uuid::Uuid::parse_str(s).ok())
6837 }
6838
6839 pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
6841 let (px, py) = self.player_position();
6842 let (cx, cy) = self.farm_plot_cell_under_player()?;
6843 let tx = cx as f32 + 0.5;
6844 let ty = cy as f32 + 0.5;
6845 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6846 return None;
6847 }
6848 let kind = self.terrain_at(tx, ty).or_else(|| self.terrain_at(px, py));
6849 if kind == Some(TerrainKindView::Tilled) {
6850 return None;
6851 }
6852 if matches!(
6853 kind,
6854 Some(TerrainKindView::ShallowWater)
6855 | Some(TerrainKindView::DeepWater)
6856 | Some(TerrainKindView::Rock)
6857 ) {
6858 return None;
6859 }
6860 Some((tx, ty))
6861 }
6862
6863 fn container_name_in_stacks(
6864 stacks: &[flatland_protocol::ItemStack],
6865 lock: &str,
6866 ) -> Option<String> {
6867 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
6868 for s in stacks {
6869 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
6870 return Some(GameState::stack_container_label(s));
6871 }
6872 if let Some(name) = walk(&s.contents, lock) {
6873 return Some(name);
6874 }
6875 }
6876 None
6877 }
6878 walk(stacks, lock)
6879 }
6880
6881 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
6882 stack
6883 .props
6884 .get(PROP_CUSTOM_NAME)
6885 .cloned()
6886 .or_else(|| stack.display_name.clone())
6887 .unwrap_or_else(|| stack.template_id.clone())
6888 }
6889
6890 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6891 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6892 for s in stacks {
6893 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
6894 return true;
6895 }
6896 if walk(&s.contents, lock) {
6897 return true;
6898 }
6899 }
6900 false
6901 }
6902 walk(stacks, lock)
6903 }
6904
6905 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
6906 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
6907 return Some(stack.clone());
6908 }
6909 for worn in self.worn.values() {
6910 if worn.item_instance_id == Some(instance_id) {
6911 return Some(worn.clone());
6912 }
6913 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
6914 return Some(stack.clone());
6915 }
6916 }
6917 None
6918 }
6919
6920 pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
6922 self.property_zones
6923 .iter()
6924 .enumerate()
6925 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6926 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6927 .map(|(_, z)| z)
6928 }
6929
6930 pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
6932 self.tax_zones
6933 .iter()
6934 .enumerate()
6935 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6936 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6937 .map(|(_, z)| z)
6938 }
6939
6940 pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
6942 let mut max_bps = 0u32;
6943 let mut y = y0 + 0.5;
6944 while y < y1 {
6945 let mut x = x0 + 0.5;
6946 while x < x1 {
6947 if let Some(tz) = self.tax_zone_at(x, y) {
6948 max_bps = max_bps.max(tz.rate_bps);
6949 }
6950 x += 1.0;
6951 }
6952 y += 1.0;
6953 }
6954 max_bps
6955 }
6956
6957 pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6959 let mode = self.claim_mode.as_ref()?;
6960 let w = mode.width_m.max(1) as f32;
6961 let h = mode.height_m.max(1) as f32;
6962 Some((
6963 mode.anchor_x,
6964 mode.anchor_y,
6965 mode.anchor_x + w,
6966 mode.anchor_y + h,
6967 ))
6968 }
6969
6970 pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6972 let mode = self.relocate_mode.as_ref()?;
6973 let x0 = mode.cursor_x.floor();
6974 let y0 = mode.cursor_y.floor();
6975 Some((x0, y0, x0 + 1.0, y0 + 1.0))
6976 }
6977
6978 pub fn claim_quote(&self) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
6981 let mode = self.claim_mode.as_ref()?;
6982 let zone = self.property_zones.iter().find(|z| z.id == mode.zone_id)?;
6983 let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
6984 let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
6985 let zone_area = zone_view_area_m2(zone).max(1.0);
6986 let area_frac = (area / zone_area).clamp(0.0, 1.0);
6987 let weight = self
6988 .property_plot_settings
6989 .as_ref()
6990 .map(|s| s.tax_premium_weight)
6991 .unwrap_or(0.5)
6992 .max(0.0);
6993 let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
6994 let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
6995 let purchase = ((zone.crown_price_copper as f64) * (area_frac as f64) * (premium as f64))
6996 .ceil()
6997 .max(0.0) as u64;
6998 let upkeep = if zone.upkeep_copper_per_day == 0 {
6999 0
7000 } else {
7001 ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
7002 .ceil()
7003 .max(1.0) as u64
7004 };
7005 let copper = crate::currency::copper_from_counts(&self.inventory);
7006 let can_afford = copper >= purchase;
7007 let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
7008 Some((purchase, upkeep, area, premium, can_afford, valid, reason))
7009 }
7010
7011 fn validate_claim_footprint(
7012 &self,
7013 zone: &flatland_protocol::PropertyZoneView,
7014 x0: f32,
7015 y0: f32,
7016 x1: f32,
7017 y1: f32,
7018 area: f32,
7019 ) -> (bool, String) {
7020 let min_area = self
7021 .property_plot_settings
7022 .as_ref()
7023 .map(|s| s.min_plot_area_m2)
7024 .unwrap_or(4.0);
7025 if area + f32::EPSILON < min_area {
7026 return (false, "plot too small".into());
7027 }
7028 if zone.max_area_m2.is_some_and(|m| area > m) {
7029 return (false, "plot exceeds max area".into());
7030 }
7031 if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
7032 return (false, "plot must lie inside the property zone".into());
7033 }
7034 if self
7035 .property_plots
7036 .iter()
7037 .any(|p| rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1))
7038 {
7039 return (false, "plot overlaps an existing claim".into());
7040 }
7041 (true, String::new())
7042 }
7043
7044 pub fn free_property_zone_under_player(&self) -> Option<&flatland_protocol::PropertyZoneView> {
7046 let (px, py) = self.player_position();
7047 let zone = self.property_zone_at(px, py)?;
7048 if self.property_plots.iter().any(|p| point_in_plot(px, py, p)) {
7049 return None;
7050 }
7051 Some(zone)
7052 }
7053
7054 pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
7056 let (px, py) = self.player_position();
7057 self.property_plots
7058 .iter()
7059 .find(|p| p.is_mine && point_in_plot(px, py, p))
7060 }
7061
7062 pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
7064 let (px, py) = self.player_position();
7065 self.property_plots
7066 .iter()
7067 .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
7068 }
7069
7070 pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
7072 if self.farmable_plot_under_player().is_none() {
7073 return None;
7074 }
7075 let (px, py) = self.player_position();
7076 Some((px.floor() as i32, py.floor() as i32))
7077 }
7078
7079 fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
7080 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
7081 self.resource_nodes.iter().any(|n| {
7082 let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
7083 ncx == cx && ncy == cy || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
7084 })
7085 }
7086
7087 fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
7088 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
7089 let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
7090 || self
7091 .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
7092 .is_some_and(|z| z.kind == TerrainKindView::Tilled);
7093 if !tilled {
7094 return false;
7095 }
7096 !self.resource_node_occupies_farm_cell(cx, cy)
7097 }
7098
7099 pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
7101 let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
7102 return false;
7103 };
7104 self.free_tilled_plant_slot_at(cx, cy)
7105 }
7106
7107 pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
7109 let (px, py) = self.player_position();
7110 for dy in -2..=2 {
7111 for dx in -2..=2 {
7112 let cx = px.floor() as i32 + dx;
7113 let cy = py.floor() as i32 + dy;
7114 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
7115 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
7116 continue;
7117 }
7118 if self.free_tilled_plant_slot_at(cx, cy) {
7119 return true;
7120 }
7121 }
7122 }
7123 false
7124 }
7125
7126 fn stack_is_farm_seed(&self, stack: &flatland_protocol::ItemStack) -> bool {
7127 if stack.quantity == 0 {
7128 return false;
7129 }
7130 if stack.props.contains_key("seed_for") {
7131 return true;
7132 }
7133 if let Some(entry) = self.item_catalog.get(&stack.template_id) {
7134 if entry.is_farm_seed() {
7135 return true;
7136 }
7137 }
7138 stack.template_id.ends_with("_seed")
7139 }
7140
7141 pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
7143 let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
7144 fn walk(
7145 stacks: &[flatland_protocol::ItemStack],
7146 state: &GameState,
7147 counts: &mut std::collections::HashMap<String, u32>,
7148 ) {
7149 for s in stacks {
7150 if state.stack_is_farm_seed(s) {
7151 *counts.entry(s.template_id.clone()).or_default() += s.quantity;
7152 }
7153 walk(&s.contents, state, counts);
7154 }
7155 }
7156 walk(&self.inventory_stacks, self, &mut counts);
7157 for worn in self.worn.values() {
7158 walk(std::slice::from_ref(worn), self, &mut counts);
7159 }
7160 let mut out: Vec<_> = counts
7161 .into_iter()
7162 .map(|(template_id, quantity)| {
7163 let label = self.template_display_name(&template_id);
7164 (template_id, quantity, label)
7165 })
7166 .collect();
7167 out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
7168 out
7169 }
7170
7171 pub fn first_farm_seed_template(&self) -> Option<String> {
7173 self.farm_seed_entries()
7174 .into_iter()
7175 .next()
7176 .map(|(id, _, _)| id)
7177 }
7178
7179 pub fn clamp_plant_menu(&mut self) {
7180 let n = self.farm_seed_entries().len();
7181 if n == 0 {
7182 self.plant_menu_index = 0;
7183 self.plant_quantity = 1;
7184 return;
7185 }
7186 self.plant_menu_index = self.plant_menu_index.min(n - 1);
7187 let max_qty = self
7188 .farm_seed_entries()
7189 .get(self.plant_menu_index)
7190 .map(|(_, q, _)| *q)
7191 .unwrap_or(1)
7192 .max(1);
7193 self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
7194 }
7195
7196 pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
7197 let entries = self.farm_seed_entries();
7198 let (id, max, label) = entries.get(self.plant_menu_index)?;
7199 let qty = self.plant_quantity.min(*max).max(1);
7200 Some((id.clone(), qty, label.clone()))
7201 }
7202
7203 pub fn location_context_lines(&self) -> Vec<ContextLine> {
7205 let (px, py) = self.player_position();
7206 let inside = self.effective_inside_building();
7207 let mut lines = Vec::new();
7208
7209 if let Some(kind) = self.terrain_at(px, py) {
7210 lines.push(ContextLine {
7211 on_top: true,
7212 text: format!("Terrain: {}", terrain_kind_label(kind)),
7213 });
7214 }
7215
7216 if let Some(id) = inside.as_ref() {
7217 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
7218 lines.push(ContextLine {
7219 on_top: true,
7220 text: format!("Inside: {}", b.label),
7221 });
7222 }
7223 }
7224
7225 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
7226
7227 for node in &self.resource_nodes {
7228 if node.id.starts_with("preview:") {
7229 continue;
7230 }
7231 let dist = distance(px, py, node.x, node.y);
7232 if dist > NEARBY_SCAN_M {
7233 continue;
7234 }
7235 let on_top = dist <= ON_TOP_RADIUS_M;
7236 let prefix = if on_top { "On" } else { "Near" };
7237 let name = resource_node_near_display_label(&node.label);
7238 let action = resource_node_near_action_suffix(node);
7239 nearby.push((
7240 dist,
7241 ContextLine {
7242 on_top,
7243 text: format!("{prefix}: {name} ({dist:.1}m){action}"),
7244 },
7245 ));
7246 }
7247
7248 for drop in &self.ground_drops {
7249 let dist = distance(px, py, drop.x, drop.y);
7250 if dist > INTERACTION_RADIUS_M {
7251 continue;
7252 }
7253 let on_top = dist <= ON_TOP_RADIUS_M;
7254 let name = self.template_display_name(&drop.template_id);
7255 let prefix = if on_top { "On" } else { "Near" };
7256 let qty = if drop.quantity > 1 {
7257 format!(" ×{}", drop.quantity)
7258 } else {
7259 String::new()
7260 };
7261 nearby.push((
7262 dist,
7263 ContextLine {
7264 on_top,
7265 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
7266 },
7267 ));
7268 }
7269
7270 for c in &self.placed_containers {
7271 if !self.placed_container_in_current_space(c) {
7272 continue;
7273 }
7274 let dist = distance(px, py, c.x, c.y);
7275 if dist > CONTAINER_RANGE_M {
7276 continue;
7277 }
7278 let on_top = dist <= ON_TOP_RADIUS_M;
7279 let name = self.placed_container_public_label(c);
7280 let lock = if c.locked { " [locked]" } else { "" };
7281 let prefix = if on_top { "On" } else { "Near" };
7282 nearby.push((
7283 dist,
7284 ContextLine {
7285 on_top,
7286 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
7287 },
7288 ));
7289 }
7290
7291 for npc in &self.npcs {
7292 let dist = distance(px, py, npc.x, npc.y);
7293 if dist > NEARBY_SCAN_M {
7294 continue;
7295 }
7296 let on_top = dist <= ON_TOP_RADIUS_M;
7297 let prefix = if on_top { "On" } else { "Near" };
7298 nearby.push((
7299 dist,
7300 ContextLine {
7301 on_top,
7302 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
7303 },
7304 ));
7305 }
7306
7307 for door in &self.doors {
7308 let dist = distance(px, py, door.x, door.y);
7309 if dist > DOOR_INTERACTION_RADIUS_M {
7310 continue;
7311 }
7312 let building = self
7313 .buildings
7314 .iter()
7315 .find(|b| b.id == door.building_id)
7316 .map(|b| b.label.as_str())
7317 .unwrap_or(door.building_id.as_str());
7318 let player_house = self
7319 .buildings
7320 .iter()
7321 .find(|b| b.id == door.building_id)
7322 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
7323 let action = if inside.is_some() && door.portal.is_some() {
7324 if player_house {
7325 if door.locked {
7326 "locked — l unlock · Enter exit".to_string()
7327 } else if door.open {
7328 "close · Enter exit · l lock".to_string()
7329 } else {
7330 "open · Enter exit · l lock".to_string()
7331 }
7332 } else {
7333 "exit".to_string()
7334 }
7335 } else if player_house {
7336 if door.locked {
7337 "locked — l unlock".to_string()
7338 } else if door.open {
7339 "close · Enter go inside · l lock".to_string()
7340 } else {
7341 "open · l lock".to_string()
7342 }
7343 } else {
7344 "enter".to_string()
7345 };
7346 nearby.push((
7347 dist,
7348 ContextLine {
7349 on_top: dist <= ON_TOP_RADIUS_M,
7350 text: format!("{building} door ({dist:.1}m) — f {action}"),
7351 },
7352 ));
7353 }
7354
7355 if inside.is_none() {
7356 for inter in &self.interactables {
7357 if inter.kind != "quest_board" {
7358 continue;
7359 }
7360 let dist = distance(px, py, inter.x, inter.y);
7361 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
7362 continue;
7363 }
7364 let on_top = dist <= ON_TOP_RADIUS_M;
7365 let prefix = if on_top { "On" } else { "Near" };
7366 let label = if inter.label.is_empty() {
7367 "Quest board".to_string()
7368 } else {
7369 inter.label.clone()
7370 };
7371 nearby.push((
7372 dist,
7373 ContextLine {
7374 on_top,
7375 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
7376 },
7377 ));
7378 }
7379 }
7380
7381 if self.near_liquid_fill_source() {
7382 let on_water = matches!(
7383 self.terrain_at(px, py),
7384 Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
7385 );
7386 let well = self.buildings.iter().find(|b| {
7387 b.tags.iter().any(|t| t == "well") && {
7388 let hw = b.width_m * 0.5;
7389 let hd = b.depth_m * 0.5;
7390 let nx = px.clamp(b.x - hw, b.x + hw);
7391 let ny = py.clamp(b.y - hd, b.y + hd);
7392 let dx = px - nx;
7393 let dy = py - ny;
7394 dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
7395 }
7396 });
7397 if let Some(well) = well {
7398 let name = if well.label.trim().is_empty() {
7399 "Well"
7400 } else {
7401 well.label.as_str()
7402 };
7403 nearby.push((
7404 0.0,
7405 ContextLine {
7406 on_top: true,
7407 text: format!("{name} — Use a vessel from inventory to fill"),
7408 },
7409 ));
7410 } else if on_water {
7411 if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
7412 line.text.push_str(" — Use a vessel from inventory to fill");
7413 }
7414 } else {
7415 nearby.push((
7416 0.0,
7417 ContextLine {
7418 on_top: true,
7419 text: "Water nearby — Use a vessel from inventory to fill".into(),
7420 },
7421 ));
7422 }
7423 }
7424
7425 if self.claim_mode.is_some() {
7426 nearby.push((
7427 0.0,
7428 ContextLine {
7429 on_top: true,
7430 text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
7431 .into(),
7432 },
7433 ));
7434 } else if let Some(plot) = self.my_plot_under_player() {
7435 let name = plot_public_label(plot);
7436 let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
7437 format!("{name} — f again to sell to crown")
7438 } else {
7439 format!(
7440 "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
7441 )
7442 };
7443 nearby.push((
7444 0.0,
7445 ContextLine {
7446 on_top: true,
7447 text: prompt,
7448 },
7449 ));
7450 } else if let Some(plot) = self.farmable_plot_under_player() {
7451 let name = plot_public_label(plot);
7452 let disc = if plot.farm_public {
7453 plot.public_tax_discount_bps / 100
7454 } else {
7455 plot.farm_allow
7456 .iter()
7457 .find(|g| Some(g.character_id) == self.character_id)
7458 .map(|g| g.tax_discount_bps / 100)
7459 .unwrap_or(0)
7460 };
7461 nearby.push((
7462 0.0,
7463 ContextLine {
7464 on_top: true,
7465 text: format!(
7466 "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
7467 ),
7468 },
7469 ));
7470 } else if let Some(zone) = self.free_property_zone_under_player() {
7471 let label = zone
7472 .label
7473 .as_deref()
7474 .filter(|s| !s.trim().is_empty())
7475 .unwrap_or(zone.id.as_str());
7476 nearby.push((
7477 0.0,
7478 ContextLine {
7479 on_top: true,
7480 text: format!("Claimable land: {label} — k buy plot"),
7481 },
7482 ));
7483 }
7484
7485 for entity in &self.entities {
7486 if entity.id == self.entity_id {
7487 continue;
7488 }
7489 let dist = distance(
7490 px,
7491 py,
7492 entity.transform.position.x,
7493 entity.transform.position.y,
7494 );
7495 if dist > NEARBY_SCAN_M {
7496 continue;
7497 }
7498 let label = if entity.label.is_empty() {
7499 format!("entity {}", entity.id)
7500 } else {
7501 entity.label.clone()
7502 };
7503 nearby.push((
7504 dist,
7505 ContextLine {
7506 on_top: dist <= ON_TOP_RADIUS_M,
7507 text: format!("Near: {label} ({dist:.1}m)"),
7508 },
7509 ));
7510 }
7511
7512 nearby.sort_by(|a, b| {
7513 a.0.partial_cmp(&b.0)
7514 .unwrap_or(std::cmp::Ordering::Equal)
7515 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
7516 });
7517 lines.extend(nearby.into_iter().map(|(_, l)| l));
7518
7519 if lines.is_empty() {
7520 lines.push(ContextLine {
7521 on_top: false,
7522 text: "(nothing notable nearby)".into(),
7523 });
7524 }
7525
7526 lines
7527 }
7528}
7529
7530#[derive(Debug, Clone)]
7532pub struct ContextLine {
7533 pub on_top: bool,
7534 pub text: String,
7535}
7536
7537const ON_TOP_RADIUS_M: f32 = 0.65;
7538const NEARBY_SCAN_M: f32 = 5.0;
7539
7540pub fn resource_node_near_display_label(label: &str) -> String {
7542 label
7543 .strip_suffix(" (growing)")
7544 .unwrap_or(label)
7545 .to_string()
7546}
7547
7548fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
7549 let t = label.trim();
7550 if t.is_empty() || t == id {
7551 return true;
7552 }
7553 let lower = t.to_ascii_lowercase();
7554 if lower.contains("_copy") {
7555 return true;
7556 }
7557 false
7558}
7559
7560fn humanize_item_template_label(template: &str) -> String {
7561 let base = template.rsplit('/').next().unwrap_or(template).trim();
7562 if base.is_empty() {
7563 return "Resource".into();
7564 }
7565 let stripped = base
7566 .strip_prefix("crop-")
7567 .or_else(|| base.strip_prefix("crop_"))
7568 .unwrap_or(base);
7569 stripped
7570 .split(|c: char| c == '-' || c == '_')
7571 .filter(|p| !p.is_empty())
7572 .map(|p| {
7573 let mut chars = p.chars();
7574 match chars.next() {
7575 Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
7576 None => String::new(),
7577 }
7578 })
7579 .collect::<Vec<_>>()
7580 .join(" ")
7581}
7582
7583pub fn resource_node_id_suffix(id: &str) -> String {
7585 let chars: Vec<char> = id
7586 .chars()
7587 .rev()
7588 .filter(|c| c.is_ascii_alphanumeric())
7589 .take(4)
7590 .collect();
7591 chars.into_iter().rev().collect()
7592}
7593
7594pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
7596 resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
7597}
7598
7599pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
7600 let cleaned = resource_node_near_display_label(label);
7601 let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
7602 cleaned
7603 } else if !item_template.trim().is_empty() {
7604 humanize_item_template_label(item_template)
7605 } else {
7606 id.to_string()
7607 };
7608 let suffix = resource_node_id_suffix(id);
7609 if suffix.is_empty() {
7610 friendly
7611 } else {
7612 format!("{friendly} ({suffix})")
7613 }
7614}
7615
7616pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
7618 use flatland_protocol::ResourceNodeState;
7619 if node.harvest_off {
7620 return " (decorative)".to_string();
7621 }
7622 if let Some(p) = node.growth_progress {
7623 if p < 1.0 - f32::EPSILON {
7624 let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
7625 return format!(" (growing, {pct}%)");
7626 }
7627 return " — f harvest".to_string();
7628 }
7629 match node.state {
7630 ResourceNodeState::Available => " — f harvest".to_string(),
7631 ResourceNodeState::Harvesting => " (being harvested)".to_string(),
7632 ResourceNodeState::Cooldown => " (depleted)".to_string(),
7633 }
7634}
7635
7636fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
7637 use flatland_protocol::TerrainKindView;
7638 match kind {
7639 TerrainKindView::Grass => "Grass",
7640 TerrainKindView::Dirt => "Dirt",
7641 TerrainKindView::Tilled => "Tilled",
7642 TerrainKindView::Desert => "Desert",
7643 TerrainKindView::Hill => "Hills",
7644 TerrainKindView::Bog => "Bog",
7645 TerrainKindView::Beach => "Beach",
7646 TerrainKindView::ShallowWater => "Shallow water",
7647 TerrainKindView::DeepWater => "Deep water",
7648 TerrainKindView::Trail => "Trail",
7649 TerrainKindView::Road => "Road",
7650 TerrainKindView::Rock => "Rock",
7651 }
7652}
7653
7654fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
7655 crate::world_zones::zone_rects_contain(rects, x, y)
7656}
7657
7658fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
7659 zone.rects
7660 .iter()
7661 .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
7662 .sum()
7663}
7664
7665fn claim_rect_fully_inside_zone(
7666 zone: &flatland_protocol::PropertyZoneView,
7667 x0: f32,
7668 y0: f32,
7669 x1: f32,
7670 y1: f32,
7671) -> bool {
7672 let mut y = y0 + 0.5;
7673 while y < y1 {
7674 let mut x = x0 + 0.5;
7675 while x < x1 {
7676 if !zone_rects_contain(&zone.rects, x, y) {
7677 return false;
7678 }
7679 x += 1.0;
7680 }
7681 y += 1.0;
7682 }
7683 true
7684}
7685
7686fn rects_overlap_half_open(
7687 ax0: f32,
7688 ay0: f32,
7689 ax1: f32,
7690 ay1: f32,
7691 bx0: f32,
7692 by0: f32,
7693 bx1: f32,
7694 by1: f32,
7695) -> bool {
7696 ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
7697}
7698
7699fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
7700 x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
7701}
7702
7703fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
7704 plot_public_label(p)
7705}
7706
7707fn plot_size_fallback_label(p: &flatland_protocol::PropertyPlotView) -> String {
7708 let w = (p.x1 - p.x0).abs();
7709 let d = (p.y1 - p.y0).abs();
7710 format!("Plot ({w:.0}×{d:.0} m)")
7711}
7712
7713pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
7715 let zone = p
7716 .zone_label
7717 .as_deref()
7718 .filter(|s| !s.trim().is_empty())
7719 .unwrap_or_else(|| {
7720 if p.property_zone_id.is_empty() {
7721 "Homestead"
7722 } else {
7723 p.property_zone_id.as_str()
7724 }
7725 });
7726 let label = if !p.label.trim().is_empty() {
7727 p.label.clone()
7728 } else if !p.plot_code.trim().is_empty() {
7729 p.plot_code.clone()
7730 } else {
7731 plot_size_fallback_label(p)
7732 };
7733 match p
7734 .owner_label
7735 .as_deref()
7736 .map(str::trim)
7737 .filter(|s| !s.is_empty())
7738 {
7739 Some(owner) => format!("{owner} — {zone} — {label}"),
7740 None => format!("{zone} — {label}"),
7741 }
7742}
7743
7744pub fn plot_stop_label(
7749 plots: &[flatland_protocol::PropertyPlotView],
7750 plot_id: uuid::Uuid,
7751) -> String {
7752 plots
7753 .iter()
7754 .find(|p| p.plot_id == plot_id)
7755 .map(plot_public_label)
7756 .unwrap_or_else(|| {
7757 let s = plot_id.to_string();
7758 format!("plot {}", s.get(..8).unwrap_or(s.as_str()))
7759 })
7760}
7761
7762fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
7764 let a = x0.min(x1).floor();
7765 let b = y0.min(y1).floor();
7766 let mut c = x0.max(x1).ceil();
7767 let mut d = y0.max(y1).ceil();
7768 if (c - a) < 1.0 {
7769 c = a + 1.0;
7770 }
7771 if (d - b) < 1.0 {
7772 d = b + 1.0;
7773 }
7774 (a, b, c, d)
7775}
7776
7777fn humanize_template_id(template_id: &str) -> String {
7778 if looks_like_template_uuid(template_id) {
7780 return "Unknown item".into();
7781 }
7782 template_id
7783 .split('_')
7784 .map(|word| {
7785 let mut chars = word.chars();
7786 match chars.next() {
7787 None => String::new(),
7788 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
7789 }
7790 })
7791 .collect::<Vec<_>>()
7792 .join(" ")
7793}
7794
7795fn looks_like_template_uuid(template_id: &str) -> bool {
7796 let bytes = template_id.as_bytes();
7797 if bytes.len() != 36 {
7798 return false;
7799 }
7800 let is_hex = |b: u8| b.is_ascii_hexdigit();
7801 let groups = [8usize, 4, 4, 4, 12];
7802 let mut i = 0;
7803 for (gi, &len) in groups.iter().enumerate() {
7804 if gi > 0 {
7805 if bytes.get(i) != Some(&b'-') {
7806 return false;
7807 }
7808 i += 1;
7809 }
7810 for _ in 0..len {
7811 if !bytes.get(i).copied().is_some_and(is_hex) {
7812 return false;
7813 }
7814 i += 1;
7815 }
7816 }
7817 true
7818}
7819
7820const HARVEST_RANGE_M: f32 = 1.5;
7822
7823pub struct GameClient<S: PlayConnection> {
7824 session: S,
7825 seq: Seq,
7826 pub state: GameState,
7827 last_move_forward: f32,
7828 last_move_strafe: f32,
7829}
7830
7831impl<S: PlayConnection> GameClient<S> {
7832 pub fn new(session: S) -> Self {
7833 let session_id = session.session_id();
7834 let entity_id = session.entity_id();
7835 let mut client = Self {
7836 session,
7837 seq: 0,
7838 last_move_forward: 0.0,
7839 last_move_strafe: 0.0,
7840 state: GameState {
7841 session_id,
7842 entity_id,
7843 character_id: None,
7844 tick: 0,
7845 chunk_rev: 0,
7846 content_rev: 0,
7847 publish_rev: 0,
7848 entities: Vec::new(),
7849 player: None,
7850 resource_nodes: Vec::new(),
7851 harvest_route_nodes: Vec::new(),
7852 ground_drops: Vec::new(),
7853 placed_containers: Vec::new(),
7854 buildings: Vec::new(),
7855 doors: Vec::new(),
7856 interior_map: None,
7857 npcs: Vec::new(),
7858 blueprints: Vec::new(),
7859 building_materials: Vec::new(),
7860 world_x0: 0.0,
7861 world_y0: 0.0,
7862 world_width_m: 0.0,
7863 world_height_m: 0.0,
7864 terrain_zones: Vec::new(),
7865 z_platforms: Vec::new(),
7866 z_transitions: Vec::new(),
7867 z_bands_outdoor_backup: None,
7868 world_clock: flatland_protocol::WorldClock::default(),
7869 inventory: std::collections::HashMap::new(),
7870 inventory_hints: std::collections::HashMap::new(),
7871 item_catalog: std::collections::HashMap::new(),
7872 logs: VecDeque::new(),
7873 intents_sent: 0,
7874 ticks_received: 0,
7875 connected: false,
7876 disconnect_reason: None,
7877 show_stats: false,
7878 hud_log_hidden: false,
7879 show_equip_menu: false,
7880 equip_menu_index: 0,
7881 show_craft_menu: false,
7882 show_plot_build_menu: false,
7883 plot_build_focus_wall: true,
7884 plot_build_wall_index: 0,
7885 plot_build_roof_index: 0,
7886 craft_menu_index: 0,
7887 craft_batch_quantity: 1,
7888 craft_tab: CraftTab::Ready,
7889 craft_filter: String::new(),
7890 craft_filter_focused: false,
7891 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
7892 show_shop_menu: false,
7893 shop_catalog: None,
7894 bank_panel: None,
7895 bank_menu_index: 0,
7896 bank_ui_mode: BankUiMode::Menu,
7897 storage_panel: None,
7898 market_panel: None,
7899 market_menu_index: 0,
7900 market_filter: String::new(),
7901 market_filter_focused: false,
7902 market_category_filter: None,
7903 market_buy_confirm: None,
7904 market_ui_mode: MarketUiMode::Browse,
7905 storage_menu_index: 0,
7906 storage_ui_mode: StorageUiMode::Menu,
7907 shop_tab: ShopTab::default(),
7908 shop_menu_index: 0,
7909 shop_quantity: 1,
7910 shop_trade_log: VecDeque::new(),
7911 show_npc_verb_menu: false,
7912 npc_verb_target: None,
7913 npc_verb_index: 0,
7914 npc_verb_notice: None,
7915 player_verbs: crate::social::PlayerVerbState::default(),
7916 social_chat: crate::social::SocialChatState::default(),
7917 trade_ui: crate::social::TradeUiState::default(),
7918 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
7919 show_npc_chat: false,
7920 npc_chat: None,
7921 show_inventory_menu: false,
7922 inventory_menu_index: 0,
7923 inventory_tab: InventoryTab::OnPerson,
7924 inventory_filter: String::new(),
7925 inventory_filter_focused: false,
7926 show_move_picker: false,
7927 show_rename_prompt: false,
7928 rename_plot_id: None,
7929 highlighted_plot_id: None,
7930 show_worker_rename: false,
7931 rename_buffer: String::new(),
7932 move_picker_index: 0,
7933 move_picker: None,
7934 show_grant_picker: false,
7935 grant_picker_index: 0,
7936 grant_picker: None,
7937 show_destroy_picker: false,
7938 destroy_confirm_pending: false,
7939 destroy_picker: None,
7940 combat_target: None,
7941 combat_target_label: None,
7942 ground_target: None,
7943 combat_fx: Vec::new(),
7944 ground_hazards: Vec::new(),
7945 property_zones: Vec::new(),
7946 tax_zones: Vec::new(),
7947 growth_zones: Vec::new(),
7948 biome_zones: Vec::new(),
7949 terrain_kind_nav: Vec::new(),
7950 property_plots: Vec::new(),
7951 property_plot_settings: None,
7952 claim_mode: None,
7953 relocate_mode: None,
7954 sell_plot_confirm: None,
7955 sell_plot_armed_at: None,
7956 show_plant_menu: false,
7957 plant_menu_index: 0,
7958 show_farm_access: false,
7959 farm_access_name_draft: String::new(),
7960 farm_access_discount_bps: 0,
7961 farm_access_index: 0,
7962 plant_quantity: 1,
7963 in_combat: false,
7964 auto_attack: true,
7965 combat_has_los: false,
7966 attack_cd_ticks: 0,
7967 gcd_ticks: 0,
7968 weapon_ability_id: "unarmed".into(),
7969 mainhand_template_id: None,
7970 mainhand_label: None,
7971 mainhand_instance_id: None,
7972 offhand_template_id: None,
7973 offhand_label: None,
7974 offhand_instance_id: None,
7975 mainhand_hand_slots: 1,
7976 defense: None,
7977 worn: BTreeMap::new(),
7978 carry_mass: 0.0,
7979 carry_mass_max: 0.0,
7980 encumbrance: flatland_protocol::EncumbranceState::Light,
7981 move_speed_mps: 0.0,
7982 move_speed_mult: 0.0,
7983 inventory_stacks: Vec::new(),
7984 keychain_stacks: Vec::new(),
7985 whisper_pouch_stacks: Vec::new(),
7986 combat_target_detail: None,
7987 statuses: Vec::new(),
7988 cast_progress: None,
7989 timed_channel: None,
7990 plot_build_offer: None,
7991 ability_cooldowns: Vec::new(),
7992 blocking_active: false,
7993 max_target_slots: 1,
7994 combat_slots: Vec::new(),
7995 rotation_presets: Vec::new(),
7996 known_abilities: Vec::new(),
7997 ability_meta: std::collections::HashMap::new(),
7998 ability_mastery: std::collections::HashMap::new(),
7999 hotbar: vec![None; 9],
8000 max_abilities_per_rotation: 0,
8001 show_loadout_menu: false,
8002 show_keychain_menu: false,
8003 keychain_menu_index: 0,
8004 show_rotation_editor: false,
8005 loadout_menu_index: 0,
8006 loadout_hotbar_slot: 1,
8007 loadout_ability_index: 0,
8008 loadout_focus_presets: false,
8009 rotation_editor: RotationEditorState::default(),
8010 harvest_in_progress: false,
8011 harvest_started_at: None,
8012 pending_craft_ack: None,
8013 craft_channel_blueprint_id: None,
8014 pending_worker_job_ack: None,
8015 attending_worker_instance_id: None,
8016 quest_log: Vec::new(),
8017 interactables: Vec::new(),
8018 ledger: None,
8019 career: None,
8020 character_sheet_tab: CharacterSheetTab::Character,
8021 ledger_period: LedgerPeriod::Day,
8022 show_quest_offer: false,
8023 pending_quest_offers: Vec::new(),
8024 quest_offer_index: 0,
8025 show_quest_menu: false,
8026 quest_menu_index: 0,
8027 quest_withdraw_confirm: false,
8028 hired_workers: Vec::new(),
8029 show_workers_menu: false,
8030 workers_menu_index: 0,
8031 worker_dismiss_confirmation: None,
8032 workers_menu_compact: false,
8033 worker_step_display: BTreeMap::new(),
8034 worker_error_display: BTreeMap::new(),
8035 worker_health_ring_until: BTreeMap::new(),
8036 pending_worker_hire_since: None,
8037 show_worker_give_picker: false,
8038 worker_give_picker_index: 0,
8039 worker_give_picker: None,
8040 show_worker_give_target_picker: false,
8041 worker_give_target_picker_index: 0,
8042 worker_give_target_picker: None,
8043 show_worker_take_picker: false,
8044 worker_take_picker_index: 0,
8045 worker_take_picker: None,
8046 show_worker_teach_picker: false,
8047 worker_teach_picker_index: 0,
8048 worker_teach_picker: None,
8049 worker_route_editor: None,
8050 progression_curve: None,
8051 },
8052 };
8053 client.state.apply_client_ui_prefs();
8054 client
8055 }
8056
8057 pub fn entity_id(&self) -> EntityId {
8058 self.state.entity_id
8059 }
8060
8061 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
8062 if self.state.connected {
8063 return Ok(());
8064 }
8065
8066 loop {
8067 match self.session.next_event().await {
8068 Some(SessionEvent::Welcome {
8069 session_id,
8070 entity_id,
8071 snapshot,
8072 }) => {
8073 self.state
8074 .restore_from_welcome(session_id, entity_id, &snapshot);
8075 self.state.apply_client_ui_prefs();
8076 self.state.push_log(format!(
8077 "Connected — session {session_id}, entity {entity_id}"
8078 ));
8079 return Ok(());
8080 }
8081 Some(SessionEvent::Disconnected { .. }) => {
8082 anyhow::bail!("disconnected before welcome");
8083 }
8084 Some(_) => continue,
8085 None => anyhow::bail!("session closed before welcome"),
8086 }
8087 }
8088 }
8089
8090 pub fn drain_events(&mut self) {
8092 while let Some(event) = self.session.try_next_event() {
8093 if self.handle_event_sync(event).is_err() {
8094 break;
8095 }
8096 }
8097 }
8098
8099 pub async fn next_event(&mut self) -> Option<SessionEvent> {
8101 self.session.next_event().await
8102 }
8103
8104 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
8105 self.handle_event_sync(event)
8106 }
8107
8108 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
8109 match event {
8110 SessionEvent::Welcome {
8111 session_id,
8112 entity_id,
8113 snapshot,
8114 } => {
8115 let resumed = self.state.connected;
8116 self.state
8117 .restore_from_welcome(session_id, entity_id, &snapshot);
8118 if resumed {
8119 self.state.push_log(format!(
8120 "Session restored — session {session_id}, entity {entity_id}"
8121 ));
8122 }
8123 }
8124 SessionEvent::ContentUpdated { snapshot } => {
8125 self.state
8126 .apply_snapshot_fields(&snapshot, self.state.entity_id);
8127 self.state.push_log(format!(
8128 "World updated (content rev {})",
8129 snapshot.content_rev
8130 ));
8131 }
8132 SessionEvent::QuestCatalogUpdated(update) => {
8133 self.state.push_log(format!(
8134 "Quest board updated (revision {}, {} new, {} retired)",
8135 update.revision,
8136 update.accepted.len(),
8137 update.retired.len()
8138 ));
8139 }
8140 SessionEvent::Tick(delta) => {
8141 self.state.apply_tick_fields(&delta, self.state.entity_id);
8142 self.state.ticks_received += 1;
8143 }
8144 SessionEvent::IntentAck {
8145 entity_id,
8146 seq,
8147 tick,
8148 } => {
8149 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
8150 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
8151 if *craft_seq == seq {
8152 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
8153 if batches > 1 {
8154 self.state.push_log(format!("Crafting {label} ×{batches}…"));
8155 } else {
8156 self.state.push_log(format!("Crafting {label}…"));
8157 }
8158 }
8159 }
8160 if self
8161 .state
8162 .pending_worker_job_ack
8163 .as_ref()
8164 .is_some_and(|p| p.seq == seq)
8165 {
8166 let pending = self.state.pending_worker_job_ack.take().unwrap();
8167 if pending.idle {
8168 self.state.push_log(format!(
8169 "Route cleared for {} — worker idle",
8170 pending.worker_label
8171 ));
8172 } else {
8173 self.state.push_log(format!(
8174 "Route saved for {} — {} stop(s), job loop active",
8175 pending.worker_label, pending.stop_count
8176 ));
8177 }
8178 if self
8179 .state
8180 .worker_route_editor
8181 .as_ref()
8182 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
8183 {
8184 self.close_worker_route_editor();
8185 }
8186 }
8187 }
8188 SessionEvent::Chat(msg) => {
8189 let label = match msg.channel {
8190 flatland_protocol::ChatChannel::Nearby => "nearby",
8191 flatland_protocol::ChatChannel::Direct => "speak",
8192 flatland_protocol::ChatChannel::Whisper => "whisper",
8193 flatland_protocol::ChatChannel::WhisperStone => "stone",
8194 };
8195 let clarity = match msg.clarity {
8196 flatland_protocol::ChatClarity::Clear => "",
8197 flatland_protocol::ChatClarity::Partial => "~",
8198 flatland_protocol::ChatClarity::Heavy => "…",
8199 };
8200 self.state.push_log(format!(
8201 "[{label}{clarity}] {}: {}",
8202 msg.from_name, msg.text
8203 ));
8204 let now_ms = std::time::SystemTime::now()
8205 .duration_since(std::time::UNIX_EPOCH)
8206 .map(|d| d.as_millis() as u64)
8207 .unwrap_or(0);
8208 self.state
8209 .social_chat
8210 .note_speech(&msg, self.state.entity_id, now_ms);
8211 self.state
8212 .social_chat
8213 .push(crate::social::ChatLogEntry::from_message(
8214 msg,
8215 self.state.entity_id,
8216 ));
8217 }
8218 SessionEvent::TradeOpened(panel) => {
8219 self.state.social_chat.pending_trade = None;
8220 let peer = panel.peer_name.clone();
8221 self.state.trade_ui.open(panel);
8222 self.state.social_chat.push_system(format!(
8223 "Trade open with {peer} — p present · r ready · Esc cancel"
8224 ));
8225 self.state
8226 .social_chat
8227 .push_cue(crate::social::AudioCue::TradeOpened);
8228 }
8229 SessionEvent::TradeClosed { reason } => {
8230 self.state.push_log(reason.clone());
8231 self.state.social_chat.push_system(reason);
8232 self.state.trade_ui.close();
8233 }
8234 SessionEvent::HarvestResult(result) => {
8235 self.state.clear_harvest_state();
8236 crate::harvest_trace!(
8237 entity_id = self.state.entity_id,
8238 node_id = %result.node_id,
8239 template = %result.item_template,
8240 quantity = result.quantity,
8241 client_tick = self.state.tick,
8242 "client applied harvest result"
8243 );
8244 let msg = if result.quantity == 0 {
8245 format!(
8246 "Harvested {} x0 — nothing dropped (loot table rolled empty)",
8247 result.item_template
8248 )
8249 } else {
8250 format!(
8251 "Harvested {} x{} (on the ground — press P to pick up)",
8252 result.item_template, result.quantity
8253 )
8254 };
8255 self.state.push_log(msg);
8256 }
8257 SessionEvent::CraftResult(result) => {
8258 for stack in &result.consumed {
8259 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
8260 *qty = qty.saturating_sub(stack.quantity);
8261 if *qty == 0 {
8262 self.state.inventory.remove(&stack.template_id);
8263 }
8264 }
8265 }
8266 for stack in &result.outputs {
8267 *self
8268 .state
8269 .inventory
8270 .entry(stack.template_id.clone())
8271 .or_insert(0) += stack.quantity;
8272 }
8273 self.state.craft_record_completed(&result.blueprint_id);
8274 if let Some(output) = result.outputs.first() {
8275 if result.batch_total > 1 {
8276 self.state.push_log(format!(
8277 "Crafted {} x{} ({}/{})",
8278 output.template_id,
8279 output.quantity,
8280 result.batch_index,
8281 result.batch_total
8282 ));
8283 } else {
8284 self.state.push_log(format!(
8285 "Crafted {} x{}",
8286 output.template_id, output.quantity
8287 ));
8288 }
8289 } else {
8290 self.state
8291 .push_log(format!("Craft finished: {}", result.blueprint_id));
8292 }
8293 }
8294 SessionEvent::Death(notice) => {
8295 self.state.clear_harvest_state();
8296 self.state.push_log(notice.message.clone());
8297 self.state.push_log(format!(
8298 "Respawned at ({:.1}, {:.1})",
8299 notice.respawn_x, notice.respawn_y
8300 ));
8301 }
8302 SessionEvent::Interaction(notice) => {
8303 if notice.message.starts_with("Harvest failed:") {
8304 self.state.clear_harvest_state();
8305 }
8306 if notice.message.starts_with("Can't do that:") {
8307 self.state.pending_worker_hire_since = None;
8308 self.state.pending_craft_ack = None;
8309 self.state.craft_channel_blueprint_id = None;
8310 if let Some(pending) = self.state.pending_worker_job_ack.take() {
8311 if let Some(w) = self
8312 .state
8313 .hired_workers
8314 .iter_mut()
8315 .find(|w| w.instance_id == pending.worker_instance_id)
8316 {
8317 w.route = pending.prev_route;
8318 w.mode = pending.prev_mode;
8319 w.step_label = pending.prev_step_label;
8320 w.last_error = pending.prev_last_error;
8321 }
8322 let reason = notice
8323 .message
8324 .strip_prefix("Can't do that:")
8325 .unwrap_or(¬ice.message)
8326 .trim();
8327 self.state.push_log(format!(
8328 "Route save failed for {}: {reason}",
8329 pending.worker_label
8330 ));
8331 }
8332 let reason = notice
8333 .message
8334 .strip_prefix("Can't do that:")
8335 .unwrap_or(¬ice.message)
8336 .trim();
8337 if reason.contains("already tilled") {
8338 if let Some(plot) = self.state.my_plot_under_player() {
8339 self.state.sell_plot_confirm = Some(plot.plot_id);
8340 self.state.sell_plot_armed_at = Some(Instant::now());
8341 }
8342 }
8343 }
8344 if notice.message.starts_with("Cast failed:") {
8345 self.state.cast_progress = None;
8346 }
8347 if notice.message.contains("slain the") {
8348 self.state.combat_target = None;
8349 self.state.combat_target_label = None;
8350 }
8351 if notice.message.contains("wants to trade") {
8353 if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
8354 let from_name = notice
8355 .message
8356 .split(" wants to trade")
8357 .next()
8358 .unwrap_or("Player")
8359 .to_string();
8360 self.state.social_chat.pending_trade =
8361 Some(crate::social::PendingTradeRequest {
8362 from_entity,
8363 from_name: from_name.clone(),
8364 });
8365 self.state.social_chat.push_system(format!(
8366 "{from_name} wants to trade — [Y] accept · [N] decline"
8367 ));
8368 self.state
8369 .social_chat
8370 .push_cue(crate::social::AudioCue::TradeOffer);
8371 }
8372 }
8373 if notice.message.starts_with("trade request declined") {
8374 self.state.social_chat.push_system(notice.message.clone());
8375 self.state
8376 .social_chat
8377 .push_cue(crate::social::AudioCue::TradeDeclined);
8378 }
8379 if self.state.show_npc_verb_menu && !notice.message.trim().is_empty() {
8381 self.state.npc_verb_notice = Some(notice.message.clone());
8382 self.state
8383 .social_chat
8384 .push_cue(crate::social::AudioCue::UiError);
8385 }
8386 self.state.apply_interaction_notice(¬ice);
8387 self.state.push_log(notice.message.clone());
8388 }
8389 SessionEvent::ShopOpened(catalog) => {
8390 self.state.apply_shop_catalog(catalog);
8391 }
8392 SessionEvent::BankOpened(panel) => {
8393 self.state.apply_bank_panel(panel);
8394 }
8395 SessionEvent::StorageOpened(panel) => {
8396 self.state.apply_storage_panel(panel);
8397 }
8398 SessionEvent::MarketOpened(panel) => {
8399 self.state.apply_market_panel(panel);
8400 }
8401 SessionEvent::NpcTalkOpened(opened) => {
8402 self.state.show_npc_verb_menu = false;
8403 if self.state.npc_verb_target.is_none() {
8404 self.state.npc_verb_target = Some(opened.npc_id.clone());
8405 }
8406 let label = opened.npc_label.clone();
8407 let banner = if !opened.trade_allowed {
8408 Some("Trade is unavailable right now.".to_string())
8409 } else {
8410 None
8411 };
8412 self.state.show_npc_chat = true;
8413 self.state.npc_chat = Some(NpcChatState {
8414 npc_id: opened.npc_id,
8415 npc_label: opened.npc_label,
8416 lines: if opened.greeting.is_empty() {
8417 vec![]
8418 } else {
8419 vec![format!("{label}: {}", opened.greeting)]
8420 },
8421 input: String::new(),
8422 pending: opened.greeting.is_empty(),
8423 talk_depth: opened.talk_depth,
8424 trade_allowed: opened.trade_allowed,
8425 banner,
8426 suggested_topics: opened.suggested_topics,
8427 });
8428 }
8429 SessionEvent::NpcTalkPending(_) => {
8430 if let Some(chat) = self.state.npc_chat.as_mut() {
8431 chat.pending = true;
8432 }
8433 }
8434 SessionEvent::NpcTalkReply(reply) => {
8435 if let Some(chat) = self.state.npc_chat.as_mut() {
8436 if chat.npc_id == reply.npc_id {
8437 chat.pending = false;
8438 if reply.trade_disabled {
8439 chat.trade_allowed = false;
8440 chat.banner = Some("Trade is unavailable right now.".to_string());
8441 }
8442 if reply.wind_down {
8443 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
8444 if chat.banner.is_none() {
8445 chat.banner =
8446 Some("They're wrapping up — keep it brief.".to_string());
8447 }
8448 }
8449 chat.lines
8450 .push(format!("{}: {}", chat.npc_label, reply.line));
8451 }
8452 }
8453 }
8454 SessionEvent::NpcTalkClosed(closed) => {
8455 if self
8456 .state
8457 .npc_chat
8458 .as_ref()
8459 .is_some_and(|c| c.npc_id == closed.npc_id)
8460 {
8461 self.state.show_npc_chat = false;
8462 self.state.npc_chat = None;
8463 }
8464 }
8465 SessionEvent::NpcTalkError(err) => {
8466 self.state.push_log(format!("Talk failed: {}", err.reason));
8467 if let Some(chat) = self.state.npc_chat.as_mut() {
8468 chat.pending = false;
8469 }
8470 }
8471 SessionEvent::UseResult(result) => {
8472 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
8475 *qty = qty.saturating_sub(1);
8476 if *qty == 0 {
8477 self.state.inventory.remove(&result.template_id);
8478 }
8479 }
8480 }
8481 SessionEvent::QuestOffer(offer) => {
8482 let title = offer.title.clone();
8483 self.state.push_quest_offer(offer);
8484 self.state.push_log(format!("Quest offered: {title}"));
8485 }
8486 SessionEvent::QuestAccepted(notice) => {
8487 self.state.remove_quest_offer(¬ice.quest_id);
8488 self.state.push_log(notice.message);
8489 }
8490 SessionEvent::QuestWithdrawn(notice) => {
8491 self.state.show_quest_menu = false;
8492 self.state.quest_withdraw_confirm = false;
8493 self.state.push_log(notice.message);
8494 }
8495 SessionEvent::QuestStepCompleted(notice) => {
8496 self.state.push_log(notice.message);
8497 }
8498 SessionEvent::QuestCompleted(notice) => {
8499 self.state.push_log(notice.message);
8500 }
8501 SessionEvent::Disconnected { reason } => {
8502 self.state.clear_harvest_state();
8503 self.state.connected = false;
8504 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
8505 if let Some(r) = &self.state.disconnect_reason {
8506 self.state.push_log(format!("Disconnected: {r}"));
8507 } else {
8508 self.state.push_log("Disconnected from server");
8509 }
8510 }
8511 }
8512 Ok(())
8513 }
8514
8515 pub fn is_connected(&self) -> bool {
8516 self.state.connected
8517 }
8518
8519 pub fn close_overlays(&mut self) {
8520 self.state.show_stats = false;
8521 self.state.show_craft_menu = false;
8522 self.state.show_plot_build_menu = false;
8523 self.state.show_shop_menu = false;
8524 self.state.shop_catalog = None;
8525 self.state.show_npc_verb_menu = false;
8526 self.state.npc_verb_target = None;
8527 self.state.show_npc_chat = false;
8528 self.state.npc_chat = None;
8529 self.state.show_inventory_menu = false;
8530 self.state.show_loadout_menu = false;
8531 self.state.show_rotation_editor = false;
8532 self.state.rotation_editor.reset();
8533 self.state.show_rename_prompt = false;
8534 self.state.show_worker_rename = false;
8535 self.state.rename_buffer.clear();
8536 self.state.show_move_picker = false;
8537 self.state.move_picker = None;
8538 self.state.show_destroy_picker = false;
8539 self.state.destroy_confirm_pending = false;
8540 self.state.destroy_picker = None;
8541 self.state.show_quest_offer = false;
8542 self.state.clear_quest_offers();
8543 self.state.show_quest_menu = false;
8544 self.state.quest_withdraw_confirm = false;
8545 self.state.show_workers_menu = false;
8546 self.close_worker_give_picker();
8547 self.close_worker_give_target_picker();
8548 self.close_worker_take_picker();
8549 self.close_worker_teach_picker();
8550 self.state.worker_route_editor = None;
8551 self.state.claim_mode = None;
8552 self.state.relocate_mode = None;
8553 self.state.sell_plot_confirm = None;
8554 self.state.sell_plot_armed_at = None;
8555 self.close_farm_access_panel();
8556 if self.state.show_plant_menu {
8557 self.close_plant_menu();
8558 }
8559 }
8560
8561 pub fn back_on_esc(&mut self) -> bool {
8563 if self.state.social_chat.composer_open() {
8564 self.state.social_chat.close_composer();
8565 return true;
8566 }
8567 if self.state.player_verbs.open {
8568 self.state.player_verbs.close();
8569 return true;
8570 }
8571 if self.state.whisper_pouch_ui.open {
8572 self.state.whisper_pouch_ui.open = false;
8573 return true;
8574 }
8575 if self.state.trade_ui.panel.is_some() {
8576 self.state.trade_ui.close();
8578 return true;
8579 }
8580 if self.state.show_rename_prompt {
8581 self.cancel_rename_prompt();
8582 return true;
8583 }
8584 if self.state.show_worker_rename {
8585 self.cancel_worker_rename();
8586 return true;
8587 }
8588 if self.state.show_destroy_picker {
8589 if self.state.destroy_confirm_pending {
8590 self.cancel_destroy_confirm();
8591 } else {
8592 self.close_destroy_picker();
8593 }
8594 return true;
8595 }
8596 if self.state.claim_mode.is_some() {
8597 self.cancel_claim_mode();
8598 return true;
8599 }
8600 if self.state.relocate_mode.is_some() {
8601 self.cancel_relocate_mode();
8602 return true;
8603 }
8604 if self.state.show_plant_menu {
8605 self.close_plant_menu();
8606 return true;
8607 }
8608 if self.state.show_farm_access {
8609 self.close_farm_access_panel();
8610 return true;
8611 }
8612 if self.state.sell_plot_confirm.is_some() {
8613 self.state.sell_plot_confirm = None;
8614 self.state.sell_plot_armed_at = None;
8615 self.state.push_log("Sell cancelled");
8616 return true;
8617 }
8618 if self.state.show_move_picker {
8619 self.close_move_picker();
8620 return true;
8621 }
8622 if self.state.show_rotation_editor {
8623 match self.state.rotation_editor.mode {
8624 RotationEditorMode::List => {
8625 self.state.show_rotation_editor = false;
8626 self.state.rotation_editor.reset();
8627 }
8628 RotationEditorMode::EditLabel => {
8629 self.state.rotation_editor.label_buffer.clear();
8630 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8631 }
8632 RotationEditorMode::PickAbility => {
8633 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8634 }
8635 RotationEditorMode::EditSequence => {
8636 self.state.rotation_editor.draft = None;
8637 self.state.rotation_editor.mode = RotationEditorMode::List;
8638 }
8639 }
8640 return true;
8641 }
8642 if self.state.show_inventory_menu {
8643 self.close_inventory_menu();
8644 return true;
8645 }
8646 if self.state.show_craft_menu {
8647 self.close_craft_menu();
8648 return true;
8649 }
8650 if self.state.show_plot_build_menu {
8651 self.close_plot_build_menu();
8652 return true;
8653 }
8654 if self.state.show_keychain_menu {
8655 self.close_keychain_menu();
8656 return true;
8657 }
8658 if self.state.show_quest_offer {
8659 self.quest_offer_decline();
8660 return true;
8661 }
8662 if self.state.show_shop_menu {
8663 return false;
8665 }
8666 if self.state.bank_panel.is_some() {
8667 return false;
8668 }
8669 if self.state.storage_panel.is_some() {
8670 return false;
8671 }
8672 if self.state.market_panel.is_some() {
8673 return false;
8674 }
8675 if self.state.show_npc_chat {
8676 return false;
8678 }
8679 if self.state.show_npc_verb_menu {
8680 self.state.show_npc_verb_menu = false;
8681 self.state.npc_verb_target = None;
8682 self.state.npc_verb_notice = None;
8683 return true;
8684 }
8685 if self.state.show_quest_menu {
8686 if self.state.quest_withdraw_confirm {
8687 self.state.quest_withdraw_confirm = false;
8688 } else {
8689 self.state.show_quest_menu = false;
8690 }
8691 return true;
8692 }
8693 if self.state.worker_route_editor.is_some() {
8694 if self.re_at_root_sheet() {
8696 let reopen = self.state.attending_worker_instance_id.clone();
8697 self.close_worker_route_editor();
8698 if let Some(id) = reopen {
8699 if let Some(idx) = self
8700 .state
8701 .hired_workers
8702 .iter()
8703 .position(|w| w.instance_id == id)
8704 {
8705 self.state.workers_menu_index = idx;
8706 self.state.show_workers_menu = true;
8707 }
8708 }
8709 } else {
8710 self.re_sheet_back();
8711 }
8712 return true;
8713 }
8714 if self.state.show_worker_give_picker {
8715 self.close_worker_give_picker();
8716 return true;
8717 }
8718 if self.state.show_worker_give_target_picker {
8719 self.close_worker_give_target_picker();
8720 return true;
8721 }
8722 if self.state.show_worker_take_picker {
8723 self.close_worker_take_picker();
8724 return true;
8725 }
8726 if self.state.show_worker_teach_picker {
8727 self.close_worker_teach_picker();
8728 return true;
8729 }
8730 if self.state.show_workers_menu {
8731 self.close_workers_menu_ui();
8732 return true;
8733 }
8734 if self.state.show_loadout_menu {
8735 self.state.show_loadout_menu = false;
8736 return true;
8737 }
8738 if self.state.show_stats {
8739 self.state.show_stats = false;
8740 return true;
8741 }
8742 if self.state.show_equip_menu {
8743 self.state.show_equip_menu = false;
8744 return true;
8745 }
8746 false
8747 }
8748
8749 pub fn toggle_stats(&mut self) {
8750 self.state.show_stats = !self.state.show_stats;
8751 if self.state.show_stats {
8752 self.state.character_sheet_tab = CharacterSheetTab::Character;
8753 self.state.show_craft_menu = false;
8754 self.state.show_shop_menu = false;
8755 self.state.shop_catalog = None;
8756 self.state.show_inventory_menu = false;
8757 self.state.show_equip_menu = false;
8758 }
8759 }
8760
8761 pub fn toggle_equip_menu(&mut self) {
8762 self.state.show_equip_menu = !self.state.show_equip_menu;
8763 if self.state.show_equip_menu {
8764 self.state.show_stats = false;
8765 self.state.show_craft_menu = false;
8766 self.state.show_shop_menu = false;
8767 self.state.shop_catalog = None;
8768 self.state.show_inventory_menu = false;
8769 self.state.show_loadout_menu = false;
8770 }
8771 }
8772
8773 pub fn cycle_character_sheet_tab(&mut self) {
8774 if self.state.show_stats {
8775 self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
8776 }
8777 }
8778
8779 pub fn set_ledger_period_digit(&mut self, c: char) {
8780 if self.state.show_stats {
8781 if let Some(p) = LedgerPeriod::from_digit(c) {
8782 self.state.ledger_period = p;
8783 self.state.character_sheet_tab = CharacterSheetTab::Ledger;
8784 }
8785 }
8786 }
8787
8788 pub fn cycle_ledger_period(&mut self) {
8789 if self.state.show_stats && self.state.character_sheet_tab == CharacterSheetTab::Ledger {
8790 self.state.ledger_period = self.state.ledger_period.cycle();
8791 }
8792 }
8793
8794 pub fn open_inventory_menu(&mut self) {
8795 self.state.show_inventory_menu = true;
8796 self.state.show_craft_menu = false;
8797 self.state.show_shop_menu = false;
8798 self.state.shop_catalog = None;
8799 self.state.show_stats = false;
8800 self.state.show_move_picker = false;
8801 self.state.move_picker = None;
8802 self.state.show_destroy_picker = false;
8803 self.state.destroy_confirm_pending = false;
8804 self.state.destroy_picker = None;
8805 self.state.show_rename_prompt = false;
8806 self.state.rename_plot_id = None;
8807 self.state.rename_buffer.clear();
8808 self.state.inventory_filter_focused = false;
8809 self.state.clamp_inventory_indices();
8810 }
8811
8812 pub fn close_inventory_menu(&mut self) {
8813 self.state.show_inventory_menu = false;
8814 self.state.show_move_picker = false;
8815 self.state.move_picker = None;
8816 self.close_grant_picker();
8817 self.state.show_destroy_picker = false;
8818 self.state.destroy_confirm_pending = false;
8819 self.state.destroy_picker = None;
8820 self.state.show_rename_prompt = false;
8821 self.state.rename_plot_id = None;
8822 self.state.rename_buffer.clear();
8823 self.state.inventory_filter_focused = false;
8824 }
8825
8826 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
8827 let Some(row) = self.state.inventory_selected_row() else {
8828 anyhow::bail!("inventory empty");
8829 };
8830 if GameState::is_property_deed_template(&row.stack.template_id) {
8831 let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
8832 anyhow::bail!("deed has no plot id");
8833 };
8834 let label = self
8835 .state
8836 .property_plots
8837 .iter()
8838 .find(|p| p.plot_id == plot_id)
8839 .map(|p| {
8840 if p.label.trim().is_empty() {
8841 p.plot_code.clone()
8842 } else {
8843 p.label.clone()
8844 }
8845 })
8846 .unwrap_or_else(|| {
8847 row.stack
8848 .display_name
8849 .clone()
8850 .unwrap_or_else(|| "plot".into())
8851 });
8852 self.state.rename_buffer = label;
8853 self.state.rename_plot_id = Some(plot_id);
8854 self.state.highlighted_plot_id = Some(plot_id);
8855 self.state.show_rename_prompt = true;
8856 self.state.show_worker_rename = false;
8857 self.state.show_move_picker = false;
8858 self.state.show_destroy_picker = false;
8859 self.state.destroy_confirm_pending = false;
8860 return Ok(());
8861 }
8862 if !self.state.row_is_renameable_container(&row) {
8863 anyhow::bail!("only storage containers or deeds can be renamed");
8864 }
8865 let current = row
8866 .stack
8867 .display_name
8868 .clone()
8869 .unwrap_or_else(|| row.stack.template_id.clone());
8870 self.state.rename_buffer = current;
8871 self.state.rename_plot_id = None;
8872 self.state.show_rename_prompt = true;
8873 self.state.show_worker_rename = false;
8874 self.state.show_move_picker = false;
8875 self.state.show_destroy_picker = false;
8876 self.state.destroy_confirm_pending = false;
8877 Ok(())
8878 }
8879
8880 pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
8882 let Some(plot) = self.state.my_plot_under_player().cloned() else {
8883 anyhow::bail!("stand on your plot to rename it");
8884 };
8885 let label = if plot.label.trim().is_empty() {
8886 plot.plot_code.clone()
8887 } else {
8888 plot.label.clone()
8889 };
8890 self.state.rename_buffer = label;
8891 self.state.rename_plot_id = Some(plot.plot_id);
8892 self.state.highlighted_plot_id = Some(plot.plot_id);
8893 self.state.show_rename_prompt = true;
8894 self.state.show_worker_rename = false;
8895 Ok(())
8896 }
8897
8898 pub fn cancel_rename_prompt(&mut self) {
8899 self.state.show_rename_prompt = false;
8900 self.state.rename_plot_id = None;
8901 self.state.rename_buffer.clear();
8902 }
8903
8904 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
8905 let name = self.state.rename_buffer.trim().to_string();
8906 if name.is_empty() {
8907 anyhow::bail!("name cannot be empty");
8908 }
8909 if let Some(plot_id) = self.state.rename_plot_id {
8910 if name.chars().count() > 48 {
8911 anyhow::bail!("label must be 1–48 characters");
8912 }
8913 self.seq += 1;
8914 self.session
8915 .submit_intent(Intent::RenamePropertyPlot {
8916 entity_id: self.state.entity_id,
8917 plot_id,
8918 label: name,
8919 seq: self.seq,
8920 })
8921 .await?;
8922 self.state.intents_sent += 1;
8923 self.state.show_rename_prompt = false;
8924 self.state.rename_plot_id = None;
8925 self.state.rename_buffer.clear();
8926 return Ok(());
8927 }
8928 if name.chars().count() > 32 {
8929 anyhow::bail!("name must be 1–32 characters");
8930 }
8931 let Some(row) = self.state.inventory_selected_row() else {
8932 anyhow::bail!("inventory empty");
8933 };
8934 let Some(instance_id) = row.stack.item_instance_id else {
8935 anyhow::bail!("item has no instance id");
8936 };
8937 self.seq += 1;
8938 self.session
8939 .submit_intent(Intent::RenameContainer {
8940 entity_id: self.state.entity_id,
8941 item_instance_id: instance_id,
8942 location: row.from.clone(),
8943 name,
8944 seq: self.seq,
8945 })
8946 .await?;
8947 self.state.intents_sent += 1;
8948 self.state.show_rename_prompt = false;
8949 self.state.rename_buffer.clear();
8950 Ok(())
8951 }
8952
8953 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
8954 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8955 anyhow::bail!("no worker selected");
8956 };
8957 self.state.rename_buffer = worker.label.clone();
8958 self.state.show_worker_rename = true;
8959 self.state.show_rename_prompt = false;
8960 Ok(())
8961 }
8962
8963 pub fn cancel_worker_rename(&mut self) {
8964 self.state.show_worker_rename = false;
8965 self.state.rename_buffer.clear();
8966 }
8967
8968 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
8969 let name = self.state.rename_buffer.trim().to_string();
8970 if name.is_empty() {
8971 anyhow::bail!("name cannot be empty");
8972 }
8973 if name.chars().count() > 32 {
8974 anyhow::bail!("name must be 1–32 characters");
8975 }
8976 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8977 anyhow::bail!("no worker selected");
8978 };
8979 let worker_instance_id = worker.instance_id.clone();
8980 self.seq += 1;
8981 self.session
8982 .submit_intent(Intent::RenameHiredWorker {
8983 entity_id: self.state.entity_id,
8984 worker_instance_id: worker_instance_id.clone(),
8985 name: name.clone(),
8986 seq: self.seq,
8987 })
8988 .await?;
8989 self.state.intents_sent += 1;
8990 if let Some(w) = self
8991 .state
8992 .hired_workers
8993 .iter_mut()
8994 .find(|w| w.instance_id == worker_instance_id)
8995 {
8996 w.label = name.clone();
8997 }
8998 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8999 if ed.worker_instance_id == worker_instance_id {
9000 ed.worker_label = name.clone();
9001 }
9002 }
9003 self.state.show_worker_rename = false;
9004 self.state.rename_buffer.clear();
9005 self.state.push_log(format!("Renamed worker to \"{name}\""));
9006 Ok(())
9007 }
9008
9009 pub fn toggle_inventory_menu(&mut self) {
9010 if self.state.show_inventory_menu {
9011 self.close_inventory_menu();
9012 } else {
9013 self.open_inventory_menu();
9014 }
9015 }
9016
9017 pub fn inventory_menu_move(&mut self, delta: i32) {
9019 if self.state.show_grant_picker {
9020 let Some(picker) = self.state.grant_picker.as_ref() else {
9021 return;
9022 };
9023 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
9024 let filter = picker.filter.clone();
9025 let n = labels.len();
9026 if n == 0 {
9027 return;
9028 }
9029 self.state.grant_picker_index =
9030 step_filtered_index(self.state.grant_picker_index, delta, n, |i| {
9031 list_label_matches(&labels[i], &filter)
9032 });
9033 return;
9034 }
9035 if self.state.show_move_picker {
9036 let Some(picker) = self.state.move_picker.as_ref() else {
9037 return;
9038 };
9039 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
9040 let filter = picker.filter.clone();
9041 let n = labels.len();
9042 if n == 0 {
9043 return;
9044 }
9045 self.state.move_picker_index =
9046 step_filtered_index(self.state.move_picker_index, delta, n, |i| {
9047 list_label_matches(&labels[i], &filter)
9048 });
9049 self.state.clamp_move_picker_quantity();
9050 return;
9051 }
9052 let n = self.state.inventory_selectable_rows().len();
9053 if n == 0 {
9054 return;
9055 }
9056 let idx = self.state.inventory_menu_index as i32;
9057 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9058 }
9059
9060 pub fn inventory_menu_page(&mut self, pages: i32) {
9062 if self.state.show_grant_picker {
9063 let Some(picker) = self.state.grant_picker.as_ref() else {
9064 return;
9065 };
9066 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
9067 let filter = picker.filter.clone();
9068 let n = labels.len();
9069 self.state.grant_picker_index =
9070 page_filtered_index(self.state.grant_picker_index, pages, n, |i| {
9071 list_label_matches(&labels[i], &filter)
9072 });
9073 return;
9074 }
9075 if self.state.show_move_picker {
9076 let Some(picker) = self.state.move_picker.as_ref() else {
9077 return;
9078 };
9079 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
9080 let filter = picker.filter.clone();
9081 let n = labels.len();
9082 self.state.move_picker_index =
9083 page_filtered_index(self.state.move_picker_index, pages, n, |i| {
9084 list_label_matches(&labels[i], &filter)
9085 });
9086 self.state.clamp_move_picker_quantity();
9087 return;
9088 }
9089 let n = self.state.inventory_selectable_rows().len();
9090 self.state.inventory_menu_index =
9091 page_list_index(self.state.inventory_menu_index, pages, n);
9092 }
9093
9094 pub fn cycle_inventory_tab(&mut self, forward: bool) {
9095 if self.state.show_move_picker
9096 || self.state.show_grant_picker
9097 || self.state.show_destroy_picker
9098 || self.state.show_rename_prompt
9099 || self.state.inventory_filter_focused
9100 {
9101 return;
9102 }
9103 self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
9104 self.state.inventory_menu_index = 0;
9105 self.state.clamp_inventory_indices();
9106 }
9107
9108 pub fn focus_inventory_filter(&mut self) {
9109 if self.state.show_grant_picker {
9110 if let Some(p) = self.state.grant_picker.as_mut() {
9111 p.filter_focused = true;
9112 }
9113 return;
9114 }
9115 if self.state.show_move_picker {
9116 if let Some(p) = self.state.move_picker.as_mut() {
9117 p.filter_focused = true;
9118 }
9119 return;
9120 }
9121 self.state.inventory_filter_focused = true;
9122 }
9123
9124 pub fn set_inventory_filter(&mut self, filter: String) {
9125 self.state.inventory_filter = filter;
9126 self.state.inventory_menu_index = 0;
9127 self.state.clamp_inventory_indices();
9128 }
9129
9130 pub fn append_inventory_filter_char(&mut self, ch: char) {
9131 if !is_list_filter_char(ch) {
9132 return;
9133 }
9134 if self.state.show_grant_picker {
9135 if let Some(p) = self.state.grant_picker.as_mut() {
9136 if p.filter_focused {
9137 p.filter.push(ch);
9138 self.state.grant_picker_index = 0;
9139 }
9140 }
9141 return;
9142 }
9143 if self.state.show_move_picker {
9144 if let Some(p) = self.state.move_picker.as_mut() {
9145 if p.filter_focused {
9146 p.filter.push(ch);
9147 self.state.move_picker_index = 0;
9148 self.state.clamp_move_picker_quantity();
9149 }
9150 }
9151 return;
9152 }
9153 if !self.state.inventory_filter_focused {
9154 return;
9155 }
9156 self.state.inventory_filter.push(ch);
9157 self.state.inventory_menu_index = 0;
9158 self.state.clamp_inventory_indices();
9159 }
9160
9161 pub fn inventory_filter_backspace(&mut self) {
9162 if self.state.show_grant_picker {
9163 if let Some(p) = self.state.grant_picker.as_mut() {
9164 if p.filter_focused {
9165 p.filter.pop();
9166 self.state.grant_picker_index = 0;
9167 }
9168 }
9169 return;
9170 }
9171 if self.state.show_move_picker {
9172 if let Some(p) = self.state.move_picker.as_mut() {
9173 if p.filter_focused {
9174 p.filter.pop();
9175 self.state.move_picker_index = 0;
9176 self.state.clamp_move_picker_quantity();
9177 }
9178 }
9179 return;
9180 }
9181 if !self.state.inventory_filter_focused {
9182 return;
9183 }
9184 self.state.inventory_filter.pop();
9185 self.state.inventory_menu_index = 0;
9186 self.state.clamp_inventory_indices();
9187 }
9188
9189 pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
9191 if self.state.show_grant_picker {
9192 if let Some(p) = self.state.grant_picker.as_mut() {
9193 if p.filter_focused {
9194 if !p.filter.is_empty() {
9195 p.filter.clear();
9196 self.state.grant_picker_index = 0;
9197 } else {
9198 p.filter_focused = false;
9199 }
9200 return true;
9201 }
9202 if !p.filter.is_empty() {
9203 p.filter.clear();
9204 self.state.grant_picker_index = 0;
9205 return true;
9206 }
9207 }
9208 return false;
9209 }
9210 if self.state.show_move_picker {
9211 if let Some(p) = self.state.move_picker.as_mut() {
9212 if p.filter_focused {
9213 if !p.filter.is_empty() {
9214 p.filter.clear();
9215 self.state.move_picker_index = 0;
9216 self.state.clamp_move_picker_quantity();
9217 } else {
9218 p.filter_focused = false;
9219 }
9220 return true;
9221 }
9222 if !p.filter.is_empty() {
9223 p.filter.clear();
9224 self.state.move_picker_index = 0;
9225 self.state.clamp_move_picker_quantity();
9226 return true;
9227 }
9228 }
9229 return false;
9230 }
9231 if self.state.inventory_filter_focused {
9232 if !self.state.inventory_filter.is_empty() {
9233 self.state.inventory_filter.clear();
9234 self.state.inventory_menu_index = 0;
9235 self.state.clamp_inventory_indices();
9236 } else {
9237 self.state.inventory_filter_focused = false;
9238 }
9239 return true;
9240 }
9241 if !self.state.inventory_filter.is_empty() {
9242 self.state.inventory_filter.clear();
9243 self.state.inventory_menu_index = 0;
9244 self.state.clamp_inventory_indices();
9245 return true;
9246 }
9247 false
9248 }
9249
9250 pub fn craft_menu_page(&mut self, pages: i32) {
9251 let n = self.state.craft_filtered_indices().len();
9252 self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
9253 self.state.clamp_craft_batch_quantity();
9254 }
9255
9256 pub fn shop_menu_page(&mut self, pages: i32) {
9257 let n = self.state.shop_list_len();
9258 self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
9259 self.state.clamp_shop_quantity();
9260 }
9261
9262 pub fn workers_menu_page(&mut self, pages: i32) {
9263 let n = self.state.hired_workers.len();
9264 self.state.workers_menu_index = page_list_index(self.state.workers_menu_index, pages, n);
9265 }
9266
9267 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
9272 if self.state.show_destroy_picker {
9273 if self.state.destroy_confirm_pending {
9274 return self.confirm_destroy_item().await;
9275 }
9276 return self.request_destroy_confirm();
9277 }
9278 if self.state.show_grant_picker {
9279 return self.confirm_grant_picker().await;
9280 }
9281 if self.state.show_move_picker {
9282 return self.confirm_move_picker().await;
9283 }
9284 let Some(row) = self.state.inventory_selected_row() else {
9285 anyhow::bail!("inventory empty");
9286 };
9287 if row.is_equip_shell {
9288 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
9289 anyhow::bail!("not a worn item");
9290 };
9291 return self.equip_worn(slot, None).await;
9292 }
9293 if row.is_chest_shell {
9294 return self.open_chest_pickup_picker();
9295 }
9296 let template_id = row.stack.template_id.clone();
9297 let instance_id = row.stack.item_instance_id;
9298 let category = self.state.inventory_item_category(&template_id);
9299 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9300
9301 if category == Some("weapon") {
9302 return self.equip_mainhand(Some(template_id)).await;
9303 }
9304 if category == Some("lodging") && on_person {
9305 if let Some(inst) = instance_id {
9306 return self.place_container(inst).await;
9307 }
9308 }
9309 if on_person {
9311 if let Some(inst) = instance_id {
9312 if row.stack.world_placeable == Some(true) {
9313 return self.place_container(inst).await;
9314 }
9315 }
9316 }
9317 if (category == Some("container") || category == Some("armor")) && on_person {
9318 if let Some(inst) = instance_id {
9319 let world_placeable =
9320 row.stack.world_placeable == Some(true) || template_id.contains("chest");
9321 if world_placeable {
9322 return self.place_container(inst).await;
9323 }
9324 if let Some(slot) = guess_body_slot(&template_id) {
9328 return self.equip_worn(slot, Some(inst)).await;
9329 }
9330 }
9331 }
9332 self.open_move_picker()
9336 }
9337
9338 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
9340 let Some(row) = self.state.inventory_selected_row() else {
9341 anyhow::bail!("inventory empty");
9342 };
9343 if row.from != flatland_protocol::InventoryLocation::Root {
9344 anyhow::bail!("select a consumable on your person");
9345 }
9346 if GameState::stack_is_item_grant(&row.stack) {
9347 return self.open_grant_target_picker();
9348 }
9349 if GameState::is_property_deed_template(&row.stack.template_id) {
9350 return self.open_move_picker();
9351 }
9352 let category = self.state.inventory_item_category(&row.stack.template_id);
9353 if category != Some("consumable") && !GameState::stack_is_serving(&row.stack) {
9354 anyhow::bail!("selected item is not usable");
9355 }
9356 self.use_item(&row.stack.template_id).await
9357 }
9358
9359 pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
9361 let Some(row) = self.state.inventory_selected_row() else {
9362 anyhow::bail!("inventory empty");
9363 };
9364 if row.from != flatland_protocol::InventoryLocation::Root {
9365 anyhow::bail!("select a grant item on your person");
9366 }
9367 if !GameState::stack_is_item_grant(&row.stack) {
9368 anyhow::bail!("selected item does not grant onto gear");
9369 }
9370 let Some(grant_instance_id) = row.stack.item_instance_id else {
9371 anyhow::bail!("grant has no instance id");
9372 };
9373 let effect_id = GameState::grant_effect_id(&row.stack)
9374 .unwrap_or("?")
9375 .to_string();
9376 let mode = GameState::grant_mode(&row.stack).to_string();
9377 let options = self.state.grant_target_options(&row.stack);
9378 if options.is_empty() {
9379 anyhow::bail!("no valid gear to apply {effect_id} to");
9380 }
9381 let grant_label = row
9382 .stack
9383 .display_name
9384 .clone()
9385 .unwrap_or_else(|| row.stack.template_id.clone());
9386 self.state.show_grant_picker = true;
9387 self.state.grant_picker_index = 0;
9388 self.state.grant_picker = Some(GrantTargetPicker {
9389 grant_instance_id,
9390 grant_label,
9391 effect_id,
9392 mode,
9393 options,
9394 filter: String::new(),
9395 filter_focused: false,
9396 });
9397 Ok(())
9398 }
9399
9400 pub fn close_grant_picker(&mut self) {
9401 self.state.show_grant_picker = false;
9402 self.state.grant_picker = None;
9403 self.state.grant_picker_index = 0;
9404 }
9405
9406 pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
9407 let Some(picker) = self.state.grant_picker.clone() else {
9408 self.close_grant_picker();
9409 return Ok(());
9410 };
9411 let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
9412 self.close_grant_picker();
9413 return Ok(());
9414 };
9415 self.close_grant_picker();
9416 self.use_grant(picker.grant_instance_id, opt.target_instance_id)
9417 .await?;
9418 self.state
9419 .push_log(format!("Applying {} onto {}…", picker.effect_id, opt.label));
9420 Ok(())
9421 }
9422
9423 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
9427 let Some(row) = self.state.inventory_selected_row() else {
9428 anyhow::bail!("inventory empty");
9429 };
9430 if row.is_equip_shell {
9431 anyhow::bail!("this is a worn bag — press Enter to unequip it");
9432 }
9433 if row.is_chest_shell {
9434 return self.open_chest_pickup_picker();
9435 }
9436 let Some(instance_id) = row.stack.item_instance_id else {
9437 anyhow::bail!("item has no instance id");
9438 };
9439 let mut options = self.state.move_destinations_for(
9440 &row.from,
9441 row.from_parent_instance_id,
9442 row.stack.item_instance_id,
9443 &row.stack.template_id,
9444 );
9445 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9446 let category = self.state.inventory_item_category(&row.stack.template_id);
9447 if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
9448 if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
9449 options.insert(
9450 0,
9451 MoveOption::action(
9452 "Sell plot to crown…",
9453 MoveOptionKind::SellPlotToCrown { plot_id },
9454 ),
9455 );
9456 }
9457 }
9458 if on_person && category == Some("consumable") {
9459 if GameState::stack_is_item_grant(&row.stack) {
9460 options.insert(
9461 0,
9462 MoveOption::action("Apply onto gear…", MoveOptionKind::GrantApply),
9463 );
9464 } else {
9465 let study = GameState::stack_is_blueprint_scroll(&row.stack);
9466 options.insert(
9467 0,
9468 MoveOption::action(
9469 if study { "Study" } else { "Use (eat / drink)" },
9470 MoveOptionKind::Use,
9471 ),
9472 );
9473 }
9474 } else if on_person && GameState::stack_is_serving(&row.stack) {
9475 let label = if GameState::stack_is_food_serving(&row.stack) {
9476 "Use (eat)"
9477 } else {
9478 "Use (fill / drink)"
9479 };
9480 options.insert(0, MoveOption::action(label, MoveOptionKind::Use));
9481 }
9482 let item_label = row
9483 .stack
9484 .display_name
9485 .clone()
9486 .unwrap_or_else(|| row.stack.template_id.clone());
9487 let initial_qty = if row.stack.quantity > 1 {
9490 1
9491 } else {
9492 row.stack.quantity
9493 };
9494 self.state.move_picker = Some(MovePicker {
9495 item_instance_id: instance_id,
9496 from: row.from,
9497 item_label,
9498 template_id: row.stack.template_id.clone(),
9499 stack_quantity: row.stack.quantity,
9500 quantity: initial_qty.max(1),
9501 options,
9502 filter: String::new(),
9503 filter_focused: false,
9504 });
9505 self.state.move_picker_index = 0;
9506 self.state.show_move_picker = true;
9507 self.state.show_destroy_picker = false;
9508 self.state.destroy_confirm_pending = false;
9509 self.state.destroy_picker = None;
9510 self.state.clamp_move_picker_quantity();
9511 Ok(())
9512 }
9513
9514 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
9516 let Some(row) = self.state.inventory_selected_row() else {
9517 anyhow::bail!("inventory empty");
9518 };
9519 if !row.is_chest_shell {
9520 anyhow::bail!("not a placed chest");
9521 }
9522 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
9523 anyhow::bail!("not a placed chest");
9524 };
9525 let Some(instance_id) = row.stack.item_instance_id else {
9526 anyhow::bail!("chest has no instance id");
9527 };
9528 let chest = self
9529 .state
9530 .placed_containers
9531 .iter()
9532 .find(|c| c.id == *container_id)
9533 .cloned()
9534 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9535 let (px, py) = self.state.player_position();
9536 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9537 anyhow::bail!("too far from {}", chest.display_name);
9538 }
9539 if chest.locked && !chest.accessible {
9540 anyhow::bail!(
9541 "need the matching key for {} before picking it up",
9542 chest.display_name
9543 );
9544 }
9545 let options = self.state.chest_pickup_destinations(container_id);
9546 let item_label = row
9547 .stack
9548 .display_name
9549 .clone()
9550 .unwrap_or_else(|| row.stack.template_id.clone());
9551 self.state.move_picker = Some(MovePicker {
9552 item_instance_id: instance_id,
9553 from: row.from.clone(),
9554 item_label,
9555 template_id: row.stack.template_id.clone(),
9556 stack_quantity: 1,
9557 quantity: 1,
9558 options,
9559 filter: String::new(),
9560 filter_focused: false,
9561 });
9562 self.state.move_picker_index = 0;
9563 self.state.show_move_picker = true;
9564 self.state.show_destroy_picker = false;
9565 self.state.destroy_confirm_pending = false;
9566 self.state.destroy_picker = None;
9567 Ok(())
9568 }
9569
9570 pub fn close_move_picker(&mut self) {
9571 self.state.show_move_picker = false;
9572 self.state.move_picker = None;
9573 }
9574
9575 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
9576 self.state.move_picker_adjust_quantity(delta);
9577 }
9578
9579 pub fn move_picker_set_quantity_max(&mut self) {
9580 self.state.move_picker_set_quantity_max();
9581 }
9582
9583 pub fn move_picker_set_quantity_min(&mut self) {
9584 self.state.move_picker_set_quantity_min();
9585 }
9586
9587 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
9588 self.state.destroy_picker_adjust_quantity(delta);
9589 }
9590
9591 pub fn destroy_picker_set_quantity_max(&mut self) {
9592 self.state.destroy_picker_set_quantity_max();
9593 }
9594
9595 pub fn destroy_picker_set_quantity_min(&mut self) {
9596 self.state.destroy_picker_set_quantity_min();
9597 }
9598
9599 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
9600 let Some(picker) = self.state.move_picker.clone() else {
9601 self.close_move_picker();
9602 return Ok(());
9603 };
9604 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
9605 self.close_move_picker();
9606 return Ok(());
9607 };
9608 match option.kind {
9609 MoveOptionKind::Cancel => {
9610 self.close_move_picker();
9611 }
9612 MoveOptionKind::Use => {
9613 self.close_move_picker();
9614 self.use_item(&picker.template_id).await?;
9615 }
9616 MoveOptionKind::GrantApply => {
9617 self.close_move_picker();
9618 self.open_grant_target_picker()?;
9619 }
9620 MoveOptionKind::SellPlotToCrown { plot_id } => {
9621 self.close_move_picker();
9622 self.confirm_sell_plot_to_crown(plot_id).await?;
9623 }
9624 MoveOptionKind::RelocatePlaced { container_id } => {
9625 self.close_move_picker();
9626 self.state.show_inventory_menu = false;
9627 self.begin_relocate_container(&container_id)?;
9628 }
9629 MoveOptionKind::Drop => {
9630 self.close_move_picker();
9631 if self
9632 .state
9633 .hand_equipped_instance_ids()
9634 .contains(&picker.item_instance_id)
9635 {
9636 anyhow::bail!("unequip that item first");
9637 }
9638 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
9639 if self.state.deed_bound(&stack) {
9640 anyhow::bail!(
9641 "cannot drop a property deed — store it or trade it to another player"
9642 );
9643 }
9644 if self.state.key_drop_blocked(&stack) {
9645 anyhow::bail!("cannot drop the key while its chest is locked");
9646 }
9647 }
9648 self.drop_item(picker.item_instance_id, picker.from).await?;
9649 self.state
9650 .push_log(format!("Dropped {}", picker.item_label));
9651 }
9652 MoveOptionKind::PickupPlaced {
9653 container_id,
9654 nest_location,
9655 nest_parent_instance_id,
9656 } => {
9657 self.close_move_picker();
9658 self.pickup_container(container_id.clone()).await?;
9659 let nest_into_bag = nest_parent_instance_id.is_some()
9660 || !matches!(nest_location, flatland_protocol::InventoryLocation::Root);
9661 if nest_into_bag {
9662 self.move_item(
9663 picker.item_instance_id,
9664 flatland_protocol::InventoryLocation::Root,
9665 nest_location,
9666 nest_parent_instance_id,
9667 None,
9668 )
9669 .await?;
9670 self.state
9671 .push_log(format!("Picked up {} into bag", picker.item_label));
9672 } else {
9673 self.state
9674 .push_log(format!("Picked up {}", picker.item_label));
9675 }
9676 }
9677 MoveOptionKind::Move {
9678 location,
9679 parent_instance_id,
9680 } => {
9681 self.close_move_picker();
9682 let qty = if picker.quantity >= picker.stack_quantity {
9683 None
9684 } else {
9685 Some(picker.quantity)
9686 };
9687 self.move_item(
9688 picker.item_instance_id,
9689 picker.from,
9690 location,
9691 parent_instance_id,
9692 qty,
9693 )
9694 .await?;
9695 let moved = qty.unwrap_or(picker.stack_quantity);
9696 if moved >= picker.stack_quantity {
9697 self.state.push_log(format!("Moved {}", picker.item_label));
9698 } else {
9699 self.state.push_log(format!(
9700 "Moved {} ×{} of {}",
9701 picker.item_label, moved, picker.stack_quantity
9702 ));
9703 }
9704 }
9705 }
9706 Ok(())
9707 }
9708
9709 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
9713 let Some(row) = self.state.inventory_selected_row() else {
9714 anyhow::bail!("inventory empty");
9715 };
9716 if row.is_equip_shell {
9717 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
9718 }
9719 if row.is_chest_shell {
9720 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
9721 }
9722 let Some(inst) = row.stack.item_instance_id else {
9723 anyhow::bail!("item has no instance id");
9724 };
9725 if self.state.hand_equipped_instance_ids().contains(&inst) {
9726 anyhow::bail!("unequip that item first");
9727 }
9728 if self.state.deed_bound(&row.stack) {
9729 anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
9730 }
9731 if self.state.key_drop_blocked(&row.stack) {
9732 anyhow::bail!("cannot drop the key while its chest is locked");
9733 }
9734 let label = row
9735 .stack
9736 .display_name
9737 .clone()
9738 .unwrap_or_else(|| row.stack.template_id.clone());
9739 let placeable = row.stack.world_placeable == Some(true)
9740 || row.from == flatland_protocol::InventoryLocation::Root
9741 && matches!(
9742 self.state
9743 .inventory_item_category(&row.stack.template_id)
9744 .as_deref(),
9745 Some("lodging")
9746 );
9747 if placeable && row.from == flatland_protocol::InventoryLocation::Root {
9748 self.place_container(inst).await?;
9749 self.state.push_log(format!("Placed {label}"));
9750 return Ok(());
9751 }
9752 self.drop_item(inst, row.from).await?;
9753 self.state.push_log(format!("Dropped {label}"));
9754 Ok(())
9755 }
9756
9757 pub async fn drop_item(
9758 &mut self,
9759 item_instance_id: uuid::Uuid,
9760 from: flatland_protocol::InventoryLocation,
9761 ) -> anyhow::Result<()> {
9762 self.seq += 1;
9763 self.session
9764 .submit_intent(Intent::DropItem {
9765 entity_id: self.state.entity_id,
9766 item_instance_id,
9767 from,
9768 seq: self.seq,
9769 })
9770 .await?;
9771 self.state.intents_sent += 1;
9772 Ok(())
9773 }
9774
9775 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
9777 let Some(row) = self.state.inventory_selected_row() else {
9778 anyhow::bail!("inventory empty");
9779 };
9780 if row.is_equip_shell {
9781 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
9782 }
9783 if row.is_chest_shell {
9784 anyhow::bail!("can't destroy a placed chest from the inventory list");
9785 }
9786 let Some(instance_id) = row.stack.item_instance_id else {
9787 anyhow::bail!("item has no instance id");
9788 };
9789 if self
9790 .state
9791 .hand_equipped_instance_ids()
9792 .contains(&instance_id)
9793 {
9794 anyhow::bail!("unequip that item first");
9795 }
9796 if self.state.deed_bound(&row.stack) {
9797 anyhow::bail!(
9798 "cannot destroy a property deed — store it or trade it to another player"
9799 );
9800 }
9801 if self.state.key_drop_blocked(&row.stack) {
9802 anyhow::bail!("cannot destroy the key while its chest is locked");
9803 }
9804 let item_label = row
9805 .stack
9806 .display_name
9807 .clone()
9808 .unwrap_or_else(|| row.stack.template_id.clone());
9809 self.state.destroy_picker = Some(DestroyPicker {
9810 item_instance_id: instance_id,
9811 from: row.from,
9812 item_label,
9813 stack_quantity: row.stack.quantity,
9814 quantity: row.stack.quantity,
9815 });
9816 self.state.destroy_confirm_pending = false;
9817 self.state.show_destroy_picker = true;
9818 self.state.show_move_picker = false;
9819 self.state.move_picker = None;
9820 Ok(())
9821 }
9822
9823 pub fn close_destroy_picker(&mut self) {
9824 self.state.show_destroy_picker = false;
9825 self.state.destroy_confirm_pending = false;
9826 self.state.destroy_picker = None;
9827 }
9828
9829 pub fn cancel_destroy_confirm(&mut self) {
9830 self.state.destroy_confirm_pending = false;
9831 }
9832
9833 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
9834 if self.state.destroy_picker.is_none() {
9835 self.close_destroy_picker();
9836 return Ok(());
9837 }
9838 self.state.destroy_confirm_pending = true;
9839 Ok(())
9840 }
9841
9842 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
9843 let Some(picker) = self.state.destroy_picker.clone() else {
9844 self.close_destroy_picker();
9845 return Ok(());
9846 };
9847 let qty = if picker.quantity >= picker.stack_quantity {
9848 None
9849 } else {
9850 Some(picker.quantity)
9851 };
9852 self.destroy_item(picker.item_instance_id, picker.from, qty)
9853 .await?;
9854 let destroyed = qty.unwrap_or(picker.stack_quantity);
9855 if destroyed >= picker.stack_quantity {
9856 self.state
9857 .push_log(format!("Destroyed {}", picker.item_label));
9858 } else {
9859 self.state.push_log(format!(
9860 "Destroyed {} ×{} of {}",
9861 picker.item_label, destroyed, picker.stack_quantity
9862 ));
9863 }
9864 self.close_destroy_picker();
9865 Ok(())
9866 }
9867
9868 pub async fn destroy_item(
9869 &mut self,
9870 item_instance_id: uuid::Uuid,
9871 from: flatland_protocol::InventoryLocation,
9872 quantity: Option<u32>,
9873 ) -> anyhow::Result<()> {
9874 self.seq += 1;
9875 self.session
9876 .submit_intent(Intent::DestroyItem {
9877 entity_id: self.state.entity_id,
9878 item_instance_id,
9879 from,
9880 quantity,
9881 seq: self.seq,
9882 })
9883 .await?;
9884 self.state.intents_sent += 1;
9885 Ok(())
9886 }
9887
9888 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
9890 if let Some(row) = self.state.inventory_selected_row() {
9891 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
9892 return self.toggle_placed_chest_lock(container_id).await;
9893 }
9894 }
9895 self.toggle_nearby_chest_lock().await
9896 }
9897
9898 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
9899 let chest = self
9900 .state
9901 .placed_containers
9902 .iter()
9903 .find(|c| c.id == container_id)
9904 .cloned()
9905 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9906 let (px, py) = self.state.player_position();
9907 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9908 anyhow::bail!("too far from {}", chest.display_name);
9909 }
9910 if !chest.accessible && chest.locked {
9911 anyhow::bail!(
9912 "need the matching key for {} (each crafted chest has its own key)",
9913 chest.display_name
9914 );
9915 }
9916 let lock = !chest.locked;
9917 self.set_container_locked(
9918 flatland_protocol::InventoryLocation::Placed {
9919 container_id: chest.id.clone(),
9920 },
9921 lock,
9922 )
9923 .await?;
9924 self.state.push_log(if lock {
9925 format!("Locked {}", chest.display_name)
9926 } else {
9927 format!("Unlocked {}", chest.display_name)
9928 });
9929 Ok(())
9930 }
9931
9932 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
9934 let chest = self
9935 .state
9936 .nearest_placed_container(CONTAINER_RANGE_M)
9937 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
9938 self.toggle_placed_chest_lock(&chest.id).await
9939 }
9940
9941 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
9942 self.equip_mainhand(None).await
9943 }
9944
9945 pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
9946 if !self.state.is_alive() {
9947 anyhow::bail!("you are dead");
9948 }
9949 self.seq += 1;
9950 self.session
9951 .submit_intent(Intent::EquipOffhand {
9952 entity_id: self.state.entity_id,
9953 template_id,
9954 instance_id: None,
9955 seq: self.seq,
9956 })
9957 .await?;
9958 self.state.intents_sent += 1;
9959 Ok(())
9960 }
9961
9962 pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
9963 self.equip_offhand(None).await
9964 }
9965
9966 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
9967 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
9968 for slot in slots {
9969 self.equip_worn(slot, None).await?;
9970 }
9971 Ok(())
9972 }
9973
9974 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
9975 let (px, py) = self.state.player_position();
9976 let in_range: Vec<_> = self
9977 .state
9978 .placed_containers
9979 .iter()
9980 .filter(|c| self.state.placed_container_in_current_space(c))
9981 .filter(|c| (c.x - px).hypot(c.y - py) <= 2.0)
9982 .collect();
9983 let nearest_free = in_range
9984 .iter()
9985 .copied()
9986 .filter(|c| !self.state.lodging_is_occupied(&c.id))
9987 .min_by(|a, b| {
9988 let da = (a.x - px).hypot(a.y - py);
9989 let db = (b.x - px).hypot(b.y - py);
9990 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
9991 })
9992 .cloned();
9993 if let Some(chest) = nearest_free {
9994 return self.pickup_container(chest.id).await;
9995 }
9996 if in_range
9997 .iter()
9998 .any(|c| self.state.lodging_is_occupied(&c.id))
9999 {
10000 anyhow::bail!("dismiss or reassign workers before picking up lodging");
10001 }
10002 if in_range.is_empty() {
10003 anyhow::bail!("no chest nearby");
10004 }
10005 anyhow::bail!("too far from chest");
10006 }
10007
10008 pub async fn equip_worn(
10009 &mut self,
10010 slot: BodySlot,
10011 instance_id: Option<uuid::Uuid>,
10012 ) -> anyhow::Result<()> {
10013 self.seq += 1;
10014 self.session
10015 .submit_intent(Intent::EquipWorn {
10016 entity_id: self.state.entity_id,
10017 slot,
10018 instance_id,
10019 seq: self.seq,
10020 })
10021 .await?;
10022 self.state.intents_sent += 1;
10023 Ok(())
10024 }
10025
10026 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
10027 self.seq += 1;
10028 self.session
10029 .submit_intent(Intent::PlaceContainer {
10030 entity_id: self.state.entity_id,
10031 item_instance_id,
10032 seq: self.seq,
10033 })
10034 .await?;
10035 self.state.intents_sent += 1;
10036 Ok(())
10037 }
10038
10039 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
10040 self.seq += 1;
10041 self.session
10042 .submit_intent(Intent::PickupContainer {
10043 entity_id: self.state.entity_id,
10044 container_id,
10045 seq: self.seq,
10046 })
10047 .await?;
10048 self.state.intents_sent += 1;
10049 Ok(())
10050 }
10051
10052 pub async fn move_item(
10053 &mut self,
10054 item_instance_id: uuid::Uuid,
10055 from: flatland_protocol::InventoryLocation,
10056 to: flatland_protocol::InventoryLocation,
10057 to_parent_instance_id: Option<uuid::Uuid>,
10058 quantity: Option<u32>,
10059 ) -> anyhow::Result<()> {
10060 self.seq += 1;
10061 self.session
10062 .submit_intent(Intent::MoveItem {
10063 entity_id: self.state.entity_id,
10064 item_instance_id,
10065 from,
10066 to,
10067 to_parent_instance_id,
10068 quantity,
10069 seq: self.seq,
10070 })
10071 .await?;
10072 self.state.intents_sent += 1;
10073 Ok(())
10074 }
10075
10076 pub async fn set_container_locked(
10077 &mut self,
10078 location: flatland_protocol::InventoryLocation,
10079 locked: bool,
10080 ) -> anyhow::Result<()> {
10081 self.seq += 1;
10082 self.session
10083 .submit_intent(Intent::SetContainerLocked {
10084 entity_id: self.state.entity_id,
10085 location,
10086 locked,
10087 seq: self.seq,
10088 })
10089 .await?;
10090 self.state.intents_sent += 1;
10091 Ok(())
10092 }
10093
10094 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
10095 if !self.state.is_alive() {
10096 anyhow::bail!("you are dead");
10097 }
10098 self.seq += 1;
10099 self.session
10100 .submit_intent(Intent::Use {
10101 entity_id: self.state.entity_id,
10102 template_id: template_id.to_string(),
10103 seq: self.seq,
10104 })
10105 .await?;
10106 self.state.intents_sent += 1;
10107 Ok(())
10108 }
10109
10110 pub async fn use_grant(
10112 &mut self,
10113 grant_instance_id: uuid::Uuid,
10114 target_instance_id: uuid::Uuid,
10115 ) -> anyhow::Result<()> {
10116 if !self.state.is_alive() {
10117 anyhow::bail!("you are dead");
10118 }
10119 self.seq += 1;
10120 self.session
10121 .submit_intent(Intent::UseGrant {
10122 entity_id: self.state.entity_id,
10123 grant_instance_id,
10124 target_instance_id,
10125 seq: self.seq,
10126 })
10127 .await?;
10128 self.state.intents_sent += 1;
10129 Ok(())
10130 }
10131
10132 pub fn open_craft_menu(&mut self) {
10133 self.state.show_craft_menu = true;
10134 self.state.show_shop_menu = false;
10135 self.state.shop_catalog = None;
10136 self.state.show_stats = false;
10137 self.state.show_inventory_menu = false;
10138 self.state.reload_craft_prefs();
10139 self.state.craft_tab = CraftTab::Ready;
10140 self.state.craft_filter.clear();
10141 self.state.craft_filter_focused = false;
10142 self.state.craft_menu_index = 0;
10143 self.state.clamp_craft_menu_index();
10144 self.state.craft_batch_quantity = 1;
10145 self.state.clamp_craft_batch_quantity();
10146 }
10147
10148 pub fn close_craft_menu(&mut self) {
10149 self.state.show_craft_menu = false;
10150 self.state.craft_filter_focused = false;
10151 }
10152
10153 pub fn toggle_keychain_menu(&mut self) {
10154 if self.state.show_keychain_menu {
10155 self.close_keychain_menu();
10156 } else {
10157 self.state.show_keychain_menu = true;
10158 self.state.show_craft_menu = false;
10159 self.state.show_shop_menu = false;
10160 self.state.show_inventory_menu = false;
10161 let n = self.state.keychain_entries().len();
10162 if n == 0 {
10163 self.state.keychain_menu_index = 0;
10164 } else {
10165 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
10166 }
10167 }
10168 }
10169
10170 pub fn close_keychain_menu(&mut self) {
10171 self.state.show_keychain_menu = false;
10172 }
10173
10174 pub fn keychain_menu_move(&mut self, delta: i32) {
10175 let n = self.state.keychain_entries().len();
10176 if n == 0 {
10177 self.state.keychain_menu_index = 0;
10178 return;
10179 }
10180 let idx = self.state.keychain_menu_index as i32 + delta;
10181 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
10182 }
10183
10184 pub fn keychain_menu_page(&mut self, pages: i32) {
10185 let n = self.state.keychain_entries().len();
10186 self.state.keychain_menu_index = page_list_index(self.state.keychain_menu_index, pages, n);
10187 }
10188
10189 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
10190 if !self.state.is_alive() {
10191 anyhow::bail!("you are dead");
10192 }
10193 let entries = self.state.keychain_entries();
10194 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
10195 anyhow::bail!("nothing selected");
10196 };
10197 let Some(instance_id) = entry.stack.item_instance_id else {
10198 anyhow::bail!("key has no instance id");
10199 };
10200 if entry.stowed {
10201 self.move_item(
10202 instance_id,
10203 flatland_protocol::InventoryLocation::Keychain,
10204 flatland_protocol::InventoryLocation::Root,
10205 None,
10206 Some(1),
10207 )
10208 .await
10209 } else {
10210 self.move_item(
10211 instance_id,
10212 flatland_protocol::InventoryLocation::Root,
10213 flatland_protocol::InventoryLocation::Keychain,
10214 None,
10215 Some(1),
10216 )
10217 .await
10218 }
10219 }
10220
10221 pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
10222 let npc_id = self.state.shop_catalog.as_ref().map(|c| c.npc_id.clone());
10223 self.state.show_shop_menu = false;
10224 self.state.shop_catalog = None;
10225 self.state.clear_shop_trade_log();
10226 if let Some(npc_id) = npc_id {
10227 self.seq += 1;
10228 self.session
10229 .submit_intent(Intent::ShopClose {
10230 entity_id: self.state.entity_id,
10231 npc_id,
10232 seq: self.seq,
10233 })
10234 .await?;
10235 self.state.intents_sent += 1;
10236 }
10237 Ok(())
10238 }
10239
10240 pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10241 let Some(panel) = self.state.bank_panel.clone() else {
10242 return Ok(());
10243 };
10244 self.seq += 1;
10245 self.session
10246 .submit_intent(Intent::BankDeposit {
10247 entity_id: self.state.entity_id,
10248 npc_id: panel.npc_id,
10249 amount_copper,
10250 seq: self.seq,
10251 })
10252 .await?;
10253 self.state.intents_sent += 1;
10254 Ok(())
10255 }
10256
10257 pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10258 let Some(panel) = self.state.bank_panel.clone() else {
10259 return Ok(());
10260 };
10261 self.seq += 1;
10262 self.session
10263 .submit_intent(Intent::BankWithdraw {
10264 entity_id: self.state.entity_id,
10265 npc_id: panel.npc_id,
10266 amount_copper,
10267 seq: self.seq,
10268 })
10269 .await?;
10270 self.state.intents_sent += 1;
10271 Ok(())
10272 }
10273
10274 pub async fn bank_transfer(
10275 &mut self,
10276 to_character_id: Option<uuid::Uuid>,
10277 to_name: String,
10278 amount_copper: u64,
10279 ) -> anyhow::Result<()> {
10280 let Some(panel) = self.state.bank_panel.clone() else {
10281 return Ok(());
10282 };
10283 self.seq += 1;
10284 self.session
10285 .submit_intent(Intent::BankTransfer {
10286 entity_id: self.state.entity_id,
10287 npc_id: panel.npc_id,
10288 to_character_id,
10289 to_name,
10290 amount_copper,
10291 seq: self.seq,
10292 })
10293 .await?;
10294 self.state.intents_sent += 1;
10295 Ok(())
10296 }
10297
10298 pub fn bank_menu_move(&mut self, delta: i32) {
10299 let n = self.state.bank_menu_options().len();
10300 if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
10301 return;
10302 }
10303 let idx = self.state.bank_menu_index as i32;
10304 self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10305 }
10306
10307 pub fn storage_menu_move(&mut self, delta: i32) {
10308 let n = self.state.storage_menu_options().len();
10309 if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
10310 return;
10311 }
10312 let idx = self.state.storage_menu_index as i32;
10313 self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10314 }
10315
10316 pub fn storage_pick_move(&mut self, delta: i32) {
10317 let n = match &self.state.storage_ui_mode {
10318 StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
10319 StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
10320 self.state.storage_vault_options().len()
10321 }
10322 StorageUiMode::Menu
10323 | StorageUiMode::StoreAmount { .. }
10324 | StorageUiMode::TakeAmount { .. }
10325 | StorageUiMode::ShipAmount { .. } => 0,
10326 };
10327 if n == 0 {
10328 return;
10329 }
10330 match &mut self.state.storage_ui_mode {
10331 StorageUiMode::StorePick { index }
10332 | StorageUiMode::TakePick { index }
10333 | StorageUiMode::ShipPick { index, .. } => {
10334 *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10335 }
10336 StorageUiMode::Menu
10337 | StorageUiMode::StoreAmount { .. }
10338 | StorageUiMode::TakeAmount { .. }
10339 | StorageUiMode::ShipAmount { .. } => {}
10340 }
10341 }
10342
10343 pub fn storage_ui_back(&mut self) {
10344 self.state.storage_ui_mode = match &self.state.storage_ui_mode {
10345 StorageUiMode::StoreAmount { pick_index, .. } => {
10346 StorageUiMode::StorePick { index: *pick_index }
10347 }
10348 StorageUiMode::TakeAmount { pick_index, .. } => {
10349 StorageUiMode::TakePick { index: *pick_index }
10350 }
10351 StorageUiMode::ShipAmount {
10352 dest_building_id,
10353 dest_label,
10354 pick_index,
10355 ..
10356 } => StorageUiMode::ShipPick {
10357 dest_building_id: dest_building_id.clone(),
10358 dest_label: dest_label.clone(),
10359 index: *pick_index,
10360 },
10361 StorageUiMode::StorePick { .. }
10362 | StorageUiMode::TakePick { .. }
10363 | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
10364 StorageUiMode::Menu => StorageUiMode::Menu,
10365 };
10366 }
10367
10368 pub fn storage_amount_append_char(&mut self, c: char) {
10369 match &mut self.state.storage_ui_mode {
10370 StorageUiMode::StoreAmount { input, .. }
10371 | StorageUiMode::TakeAmount { input, .. }
10372 | StorageUiMode::ShipAmount { input, .. } => {
10373 if c.is_ascii_digit() && input.len() < 8 {
10374 input.push(c);
10375 }
10376 }
10377 _ => {}
10378 }
10379 }
10380
10381 pub fn storage_amount_backspace(&mut self) {
10382 match &mut self.state.storage_ui_mode {
10383 StorageUiMode::StoreAmount { input, .. }
10384 | StorageUiMode::TakeAmount { input, .. }
10385 | StorageUiMode::ShipAmount { input, .. } => {
10386 input.pop();
10387 }
10388 _ => {}
10389 }
10390 }
10391
10392 pub fn storage_ui_typing(&self) -> bool {
10393 matches!(
10394 self.state.storage_ui_mode,
10395 StorageUiMode::StoreAmount { .. }
10396 | StorageUiMode::TakeAmount { .. }
10397 | StorageUiMode::ShipAmount { .. }
10398 )
10399 }
10400
10401 pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
10402 match self.state.storage_ui_mode.clone() {
10403 StorageUiMode::Menu => {
10404 let index = self.state.storage_menu_index;
10405 match index {
10406 0 => {
10407 let opts = self.state.storage_store_options();
10408 if opts.is_empty() {
10409 self.state.push_log("Nothing loose to store.");
10410 return Ok(());
10411 }
10412 self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
10413 }
10414 1 => {
10415 let opts = self.state.storage_vault_options();
10416 if opts.is_empty() {
10417 self.state.push_log("Vault is empty.");
10418 return Ok(());
10419 }
10420 self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
10421 }
10422 n => {
10423 let dest = self
10424 .state
10425 .storage_panel
10426 .as_ref()
10427 .and_then(|p| p.ship_destinations.get(n - 2))
10428 .cloned();
10429 let Some(dest) = dest else {
10430 return Ok(());
10431 };
10432 let opts = self.state.storage_vault_options();
10433 if opts.is_empty() {
10434 self.state.push_log("Vault is empty — nothing to ship.");
10435 return Ok(());
10436 }
10437 self.state.storage_ui_mode = StorageUiMode::ShipPick {
10438 dest_building_id: dest.building_id,
10439 dest_label: dest.label,
10440 index: 0,
10441 };
10442 }
10443 }
10444 }
10445 StorageUiMode::StorePick { index } => {
10446 let opts = self.state.storage_store_options();
10447 let Some(opt) = opts.get(index) else {
10448 self.state.push_log("Nothing loose to store.");
10449 self.state.storage_ui_mode = StorageUiMode::Menu;
10450 return Ok(());
10451 };
10452 self.state.storage_ui_mode = StorageUiMode::StoreAmount {
10453 pick_index: index,
10454 item_instance_id: opt.item_instance_id,
10455 label: opt.label.clone(),
10456 max_qty: opt.quantity.max(1),
10457 input: String::new(),
10458 };
10459 }
10460 StorageUiMode::TakePick { index } => {
10461 let opts = self.state.storage_vault_options();
10462 let Some(opt) = opts.get(index) else {
10463 self.state.push_log("Vault is empty.");
10464 self.state.storage_ui_mode = StorageUiMode::Menu;
10465 return Ok(());
10466 };
10467 self.state.storage_ui_mode = StorageUiMode::TakeAmount {
10468 pick_index: index,
10469 item_instance_id: opt.item_instance_id,
10470 label: opt.label.clone(),
10471 max_qty: opt.quantity.max(1),
10472 input: String::new(),
10473 };
10474 }
10475 StorageUiMode::ShipPick {
10476 dest_building_id,
10477 dest_label,
10478 index,
10479 } => {
10480 let opts = self.state.storage_vault_options();
10481 let Some(opt) = opts.get(index) else {
10482 self.state.push_log("Vault is empty — nothing to ship.");
10483 self.state.storage_ui_mode = StorageUiMode::Menu;
10484 return Ok(());
10485 };
10486 self.state.storage_ui_mode = StorageUiMode::ShipAmount {
10487 dest_building_id,
10488 dest_label,
10489 pick_index: index,
10490 item_instance_id: opt.item_instance_id,
10491 label: opt.label.clone(),
10492 max_qty: opt.quantity.max(1),
10493 input: String::new(),
10494 };
10495 }
10496 StorageUiMode::StoreAmount {
10497 item_instance_id,
10498 max_qty,
10499 input,
10500 ..
10501 } => {
10502 let Some(qty) = parse_storage_quantity(&input) else {
10503 self.state.push_log("Enter a quantity (blank or 0 = all).");
10504 return Ok(());
10505 };
10506 let qty = qty.map(|n| n.min(max_qty).max(1));
10507 self.storage_store(item_instance_id, qty).await?;
10508 self.state.storage_ui_mode = StorageUiMode::Menu;
10509 }
10510 StorageUiMode::TakeAmount {
10511 item_instance_id,
10512 max_qty,
10513 input,
10514 ..
10515 } => {
10516 let Some(qty) = parse_storage_quantity(&input) else {
10517 self.state.push_log("Enter a quantity (blank or 0 = all).");
10518 return Ok(());
10519 };
10520 let qty = qty.map(|n| n.min(max_qty).max(1));
10521 self.storage_take(item_instance_id, qty).await?;
10522 self.state.storage_ui_mode = StorageUiMode::Menu;
10523 }
10524 StorageUiMode::ShipAmount {
10525 dest_building_id,
10526 item_instance_id,
10527 max_qty,
10528 input,
10529 ..
10530 } => {
10531 let Some(qty) = parse_storage_quantity(&input) else {
10532 self.state.push_log("Enter a quantity (blank or 0 = all).");
10533 return Ok(());
10534 };
10535 let qty = qty.map(|n| n.min(max_qty).max(1));
10536 self.storage_ship(dest_building_id, item_instance_id, qty)
10537 .await?;
10538 self.state.storage_ui_mode = StorageUiMode::Menu;
10539 }
10540 }
10541 Ok(())
10542 }
10543
10544 pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
10545 match self.state.bank_ui_mode.clone() {
10546 BankUiMode::Menu => {
10547 let choice = self
10548 .state
10549 .bank_menu_options()
10550 .get(self.state.bank_menu_index)
10551 .copied()
10552 .unwrap_or("Deposit…");
10553 match choice {
10554 "Withdraw…" => {
10555 self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
10556 input: String::new(),
10557 };
10558 }
10559 "Deposit all" => self.bank_deposit(0).await?,
10560 "Withdraw all" => self.bank_withdraw(0).await?,
10561 "Transfer…" => {
10562 self.state.bank_ui_mode = BankUiMode::TransferName {
10563 input: String::new(),
10564 };
10565 }
10566 _ => {
10567 self.state.bank_ui_mode = BankUiMode::DepositAmount {
10568 input: String::new(),
10569 };
10570 }
10571 }
10572 }
10573 BankUiMode::DepositAmount { input } => {
10574 let Some(amount) = parse_bank_copper_amount(&input) else {
10575 self.state
10576 .push_log("Enter a copper amount (blank or 0 = everything on person).");
10577 return Ok(());
10578 };
10579 self.bank_deposit(amount).await?;
10580 self.state.bank_ui_mode = BankUiMode::Menu;
10581 }
10582 BankUiMode::WithdrawAmount { input } => {
10583 let Some(amount) = parse_bank_copper_amount(&input) else {
10584 self.state
10585 .push_log("Enter a copper amount (blank or 0 = full ledger).");
10586 return Ok(());
10587 };
10588 self.bank_withdraw(amount).await?;
10589 self.state.bank_ui_mode = BankUiMode::Menu;
10590 }
10591 BankUiMode::TransferName { input } => {
10592 let name = input.trim().to_string();
10593 if name.is_empty() {
10594 self.state.push_log("Enter the recipient character name.");
10595 return Ok(());
10596 }
10597 self.state.bank_ui_mode = BankUiMode::TransferAmount {
10598 to_name: name,
10599 input: String::new(),
10600 };
10601 }
10602 BankUiMode::TransferAmount { to_name, input } => {
10603 let amount: u64 = match input.trim().parse() {
10604 Ok(v) if v > 0 => v,
10605 _ => {
10606 self.state
10607 .push_log("Enter a positive copper amount to transfer.");
10608 return Ok(());
10609 }
10610 };
10611 self.bank_transfer(None, to_name, amount).await?;
10612 self.state.bank_ui_mode = BankUiMode::Menu;
10613 }
10614 }
10615 Ok(())
10616 }
10617
10618 pub fn bank_transfer_back(&mut self) {
10619 match &self.state.bank_ui_mode {
10620 BankUiMode::TransferAmount { to_name, .. } => {
10621 self.state.bank_ui_mode = BankUiMode::TransferName {
10622 input: to_name.clone(),
10623 };
10624 }
10625 BankUiMode::TransferName { .. }
10626 | BankUiMode::DepositAmount { .. }
10627 | BankUiMode::WithdrawAmount { .. } => {
10628 self.state.bank_ui_mode = BankUiMode::Menu;
10629 }
10630 BankUiMode::Menu => {}
10631 }
10632 }
10633
10634 pub fn bank_transfer_append_char(&mut self, c: char) {
10635 match &mut self.state.bank_ui_mode {
10636 BankUiMode::TransferName { input } => {
10637 if input.len() < 32 && !c.is_control() {
10638 input.push(c);
10639 }
10640 }
10641 BankUiMode::DepositAmount { input }
10642 | BankUiMode::WithdrawAmount { input }
10643 | BankUiMode::TransferAmount { input, .. } => {
10644 if c.is_ascii_digit() && input.len() < 12 {
10645 input.push(c);
10646 }
10647 }
10648 BankUiMode::Menu => {}
10649 }
10650 }
10651
10652 pub fn bank_transfer_backspace(&mut self) {
10653 match &mut self.state.bank_ui_mode {
10654 BankUiMode::TransferName { input }
10655 | BankUiMode::DepositAmount { input }
10656 | BankUiMode::WithdrawAmount { input }
10657 | BankUiMode::TransferAmount { input, .. } => {
10658 input.pop();
10659 }
10660 BankUiMode::Menu => {}
10661 }
10662 }
10663
10664 pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
10665 let npc_id = self.state.bank_panel.as_ref().map(|p| p.npc_id.clone());
10666 self.state.clear_bank_panel();
10667 if let Some(npc_id) = npc_id {
10668 self.seq += 1;
10669 self.session
10670 .submit_intent(Intent::BankClose {
10671 entity_id: self.state.entity_id,
10672 npc_id,
10673 seq: self.seq,
10674 })
10675 .await?;
10676 self.state.intents_sent += 1;
10677 }
10678 Ok(())
10679 }
10680
10681 pub async fn storage_store(
10682 &mut self,
10683 item_instance_id: uuid::Uuid,
10684 quantity: Option<u32>,
10685 ) -> anyhow::Result<()> {
10686 let Some(panel) = self.state.storage_panel.clone() else {
10687 return Ok(());
10688 };
10689 self.seq += 1;
10690 self.session
10691 .submit_intent(Intent::StorageStore {
10692 entity_id: self.state.entity_id,
10693 npc_id: panel.npc_id,
10694 item_instance_id,
10695 quantity,
10696 seq: self.seq,
10697 })
10698 .await?;
10699 self.state.intents_sent += 1;
10700 Ok(())
10701 }
10702
10703 pub async fn storage_take(
10704 &mut self,
10705 item_instance_id: uuid::Uuid,
10706 quantity: Option<u32>,
10707 ) -> anyhow::Result<()> {
10708 let Some(panel) = self.state.storage_panel.clone() else {
10709 return Ok(());
10710 };
10711 self.seq += 1;
10712 self.session
10713 .submit_intent(Intent::StorageTake {
10714 entity_id: self.state.entity_id,
10715 npc_id: panel.npc_id,
10716 item_instance_id,
10717 quantity,
10718 seq: self.seq,
10719 })
10720 .await?;
10721 self.state.intents_sent += 1;
10722 Ok(())
10723 }
10724
10725 pub async fn storage_ship(
10726 &mut self,
10727 dest_building_id: String,
10728 item_instance_id: uuid::Uuid,
10729 quantity: Option<u32>,
10730 ) -> anyhow::Result<()> {
10731 let Some(panel) = self.state.storage_panel.clone() else {
10732 return Ok(());
10733 };
10734 self.seq += 1;
10735 self.session
10736 .submit_intent(Intent::StorageShip {
10737 entity_id: self.state.entity_id,
10738 npc_id: panel.npc_id,
10739 dest_building_id,
10740 item_instance_id,
10741 quantity,
10742 seq: self.seq,
10743 })
10744 .await?;
10745 self.state.intents_sent += 1;
10746 Ok(())
10747 }
10748
10749 pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
10750 let npc_id = self.state.storage_panel.as_ref().map(|p| p.npc_id.clone());
10751 self.state.clear_storage_panel();
10752 if let Some(npc_id) = npc_id {
10753 self.seq += 1;
10754 self.session
10755 .submit_intent(Intent::StorageClose {
10756 entity_id: self.state.entity_id,
10757 npc_id,
10758 seq: self.seq,
10759 })
10760 .await?;
10761 self.state.intents_sent += 1;
10762 }
10763 Ok(())
10764 }
10765
10766 pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
10767 let npc_id = self.state.market_panel.as_ref().map(|p| p.npc_id.clone());
10768 self.state.clear_market_panel();
10769 if let Some(npc_id) = npc_id {
10770 self.seq += 1;
10771 self.session
10772 .submit_intent(Intent::MarketClose {
10773 entity_id: self.state.entity_id,
10774 npc_id,
10775 seq: self.seq,
10776 })
10777 .await?;
10778 self.state.intents_sent += 1;
10779 }
10780 Ok(())
10781 }
10782
10783 pub fn market_move_selection(&mut self, delta: i32) {
10784 let indices = self.state.market_filtered_listing_indices();
10785 let n = indices.len();
10786 if n == 0 {
10787 self.state.market_menu_index = 0;
10788 return;
10789 }
10790 let cur = self.state.market_menu_index as i32;
10791 self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
10792 }
10793
10794 pub fn market_page_selection(&mut self, pages: i32) {
10795 let indices = self.state.market_filtered_listing_indices();
10796 let n = indices.len();
10797 if n == 0 {
10798 self.state.market_menu_index = 0;
10799 return;
10800 }
10801 self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
10802 }
10803
10804 pub fn market_list_page(&mut self, pages: i32) {
10805 match &self.state.market_ui_mode {
10806 MarketUiMode::ListSource { index } => {
10807 let n = self.state.market_list_source_options().len();
10808 if n == 0 {
10809 return;
10810 }
10811 let next = page_list_index(*index, pages, n);
10812 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
10813 }
10814 MarketUiMode::ListPricingMode { index, .. } => {
10815 let next = page_list_index(*index, pages, 2);
10816 if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
10817 {
10818 *index = next;
10819 }
10820 }
10821 MarketUiMode::ListPick { source, index } => {
10822 let opts = self.state.market_list_item_options(source);
10823 let n = opts.len();
10824 if n == 0 {
10825 return;
10826 }
10827 let next = page_list_index(*index, pages, n);
10828 self.state.market_ui_mode = MarketUiMode::ListPick {
10829 source: source.clone(),
10830 index: next,
10831 };
10832 }
10833 _ => {}
10834 }
10835 }
10836
10837 pub fn market_cycle_category(&mut self, delta: i32) {
10838 let groups = self.state.market_available_category_groups();
10839 let mut labels: Vec<Option<&'static str>> = vec![None];
10841 labels.extend(groups.into_iter().map(Some));
10842 let n = labels.len() as i32;
10843 let cur = labels
10844 .iter()
10845 .position(|g| *g == self.state.market_category_filter)
10846 .unwrap_or(0) as i32;
10847 let next = (cur + delta).rem_euclid(n) as usize;
10848 self.state.market_category_filter = labels[next];
10849 self.state.market_menu_index = 0;
10850 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10851 let source = source.clone();
10852 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10853 }
10854 }
10855
10856 pub fn focus_market_filter(&mut self) {
10857 self.state.market_filter_focused = true;
10858 }
10859
10860 pub fn append_market_filter_char(&mut self, ch: char) {
10861 if !self.state.market_filter_focused {
10862 return;
10863 }
10864 if !is_list_filter_char(ch) {
10865 return;
10866 }
10867 if self.state.market_filter.len() < 48 {
10868 self.state.market_filter.push(ch);
10869 self.state.market_menu_index = 0;
10870 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10871 let source = source.clone();
10872 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10873 }
10874 }
10875 }
10876
10877 pub fn market_filter_backspace(&mut self) {
10878 if !self.state.market_filter_focused {
10879 return;
10880 }
10881 self.state.market_filter.pop();
10882 self.state.market_menu_index = 0;
10883 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10884 let source = source.clone();
10885 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10886 }
10887 }
10888
10889 pub fn clear_or_blur_market_filter(&mut self) -> bool {
10891 if self.state.market_filter_focused {
10892 if !self.state.market_filter.is_empty() {
10893 self.state.market_filter.clear();
10894 self.state.market_menu_index = 0;
10895 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10896 let source = source.clone();
10897 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10898 }
10899 return true;
10900 }
10901 self.state.market_filter_focused = false;
10902 return true;
10903 }
10904 if !self.state.market_filter.is_empty() {
10905 self.state.market_filter.clear();
10906 self.state.market_menu_index = 0;
10907 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10908 let source = source.clone();
10909 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10910 }
10911 return true;
10912 }
10913 false
10914 }
10915
10916 pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
10917 if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
10918 return self.market_confirm_buy(listing_id, qty).await;
10919 }
10920 let Some(panel) = self.state.market_panel.clone() else {
10921 return Ok(());
10922 };
10923 let indices = self.state.market_filtered_listing_indices();
10924 let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
10925 return Ok(());
10926 };
10927 let Some(listing) = panel.listings.get(raw_idx) else {
10928 return Ok(());
10929 };
10930 if listing.mine {
10931 self.seq += 1;
10932 self.session
10933 .submit_intent(Intent::MarketDelist {
10934 entity_id: self.state.entity_id,
10935 npc_id: panel.npc_id.clone(),
10936 listing_id: listing.listing_id,
10937 dest: flatland_protocol::GoodsLocation::Person,
10938 seq: self.seq,
10939 })
10940 .await?;
10941 self.state.intents_sent += 1;
10942 return Ok(());
10943 }
10944 if listing.npc_price {
10945 self.state
10946 .push_log("NPC-price listings are bought by merchants only.");
10947 return Ok(());
10948 }
10949 let qty = 1u32.min(listing.quantity).max(1);
10950 let line = listing.unit_price_copper.saturating_mul(qty as u64);
10951 self.state.market_buy_confirm = Some((
10952 listing.listing_id,
10953 qty,
10954 listing.unit_price_copper,
10955 line,
10956 listing.display_name.clone(),
10957 ));
10958 Ok(())
10959 }
10960
10961 pub async fn market_confirm_buy(
10962 &mut self,
10963 listing_id: uuid::Uuid,
10964 quantity: u32,
10965 ) -> anyhow::Result<()> {
10966 let Some(panel) = self.state.market_panel.clone() else {
10967 self.state.market_buy_confirm = None;
10968 return Ok(());
10969 };
10970 self.state.market_buy_confirm = None;
10971 self.seq += 1;
10972 self.session
10973 .submit_intent(Intent::MarketBuy {
10974 entity_id: self.state.entity_id,
10975 npc_id: panel.npc_id,
10976 listing_id,
10977 quantity,
10978 dest: flatland_protocol::GoodsLocation::Person,
10979 seq: self.seq,
10980 })
10981 .await?;
10982 self.state.intents_sent += 1;
10983 Ok(())
10984 }
10985
10986 pub fn market_begin_list(&mut self) {
10988 if self.state.market_panel.is_none() {
10989 return;
10990 }
10991 let sources = self.state.market_list_source_options();
10992 if sources.is_empty() {
10993 self.state.push_log("Nothing to list from.");
10994 return;
10995 }
10996 if sources.len() == 1 {
10998 let (source, _) = sources[0].clone();
10999 let opts = self.state.market_list_item_options(&source);
11000 if opts.is_empty() {
11001 self.state.push_log("Nothing loose to list.");
11002 return;
11003 }
11004 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11005 self.state.market_buy_confirm = None;
11006 return;
11007 }
11008 self.state.market_buy_confirm = None;
11009 self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
11010 }
11011
11012 pub fn market_ui_back(&mut self) {
11013 self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
11014 MarketUiMode::Browse => MarketUiMode::Browse,
11015 MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
11016 MarketUiMode::ListPick { .. } => {
11017 if self.state.market_list_source_options().len() <= 1 {
11018 MarketUiMode::Browse
11019 } else {
11020 MarketUiMode::ListSource { index: 0 }
11021 }
11022 }
11023 MarketUiMode::ListAmount {
11024 source, pick_index, ..
11025 } => MarketUiMode::ListPick {
11026 source,
11027 index: pick_index,
11028 },
11029 MarketUiMode::ListPricingMode {
11030 source,
11031 item_instance_id,
11032 template_id,
11033 label,
11034 max_qty,
11035 quantity,
11036 pick_index,
11037 ..
11038 } => {
11039 let input = quantity.map(|q| q.to_string()).unwrap_or_default();
11040 MarketUiMode::ListAmount {
11041 source,
11042 pick_index,
11043 item_instance_id,
11044 template_id,
11045 label,
11046 max_qty,
11047 input,
11048 }
11049 }
11050 MarketUiMode::ListPrice {
11051 source,
11052 pick_index,
11053 item_instance_id,
11054 template_id,
11055 label,
11056 max_qty,
11057 quantity,
11058 ..
11059 } => MarketUiMode::ListPricingMode {
11060 source,
11061 pick_index,
11062 item_instance_id,
11063 template_id,
11064 label,
11065 quantity,
11066 max_qty,
11067 index: 1,
11068 },
11069 };
11070 }
11071
11072 pub fn market_list_move(&mut self, delta: i32) {
11073 match &self.state.market_ui_mode {
11074 MarketUiMode::ListSource { index } => {
11075 let n = self.state.market_list_source_options().len();
11076 if n == 0 {
11077 return;
11078 }
11079 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
11080 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
11081 }
11082 MarketUiMode::ListPricingMode { index, .. } => {
11083 let next = (*index as i32 + delta).rem_euclid(2) as usize;
11084 if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
11085 {
11086 *index = next;
11087 }
11088 }
11089 MarketUiMode::ListPick { source, index } => {
11090 let opts = self.state.market_list_item_options(source);
11091 let n = opts.len();
11092 if n == 0 {
11093 return;
11094 }
11095 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
11096 self.state.market_ui_mode = MarketUiMode::ListPick {
11097 source: source.clone(),
11098 index: next,
11099 };
11100 }
11101 _ => {}
11102 }
11103 }
11104
11105 pub fn market_list_amount_append_char(&mut self, c: char) {
11106 if !c.is_ascii_digit() {
11107 return;
11108 }
11109 match &mut self.state.market_ui_mode {
11110 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
11111 if input.len() < 12 {
11112 input.push(c);
11113 }
11114 }
11115 _ => {}
11116 }
11117 }
11118
11119 pub fn market_list_amount_backspace(&mut self) {
11120 match &mut self.state.market_ui_mode {
11121 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
11122 input.pop();
11123 }
11124 _ => {}
11125 }
11126 }
11127
11128 pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
11129 match self.state.market_ui_mode.clone() {
11130 MarketUiMode::Browse => Ok(()),
11131 MarketUiMode::ListSource { index } => {
11132 let sources = self.state.market_list_source_options();
11133 let Some((source, _)) = sources.get(index).cloned() else {
11134 return Ok(());
11135 };
11136 let opts = self.state.market_list_item_options(&source);
11137 if opts.is_empty() {
11138 self.state.push_log("Nothing to list from that source.");
11139 return Ok(());
11140 }
11141 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11142 Ok(())
11143 }
11144 MarketUiMode::ListPick { source, index } => {
11145 let opts = self.state.market_list_item_options(&source);
11146 let Some(opt) = opts.get(index) else {
11147 self.state.push_log("Nothing to list.");
11148 self.state.market_ui_mode = MarketUiMode::Browse;
11149 return Ok(());
11150 };
11151 self.state.market_ui_mode = MarketUiMode::ListAmount {
11152 source,
11153 pick_index: index,
11154 item_instance_id: opt.item_instance_id,
11155 template_id: opt.template_id.clone(),
11156 label: opt.label.clone(),
11157 max_qty: opt.quantity.max(1),
11158 input: String::new(),
11159 };
11160 Ok(())
11161 }
11162 MarketUiMode::ListAmount {
11163 source,
11164 pick_index,
11165 item_instance_id,
11166 template_id,
11167 label,
11168 max_qty,
11169 input,
11170 ..
11171 } => {
11172 let Some(qty_opt) = parse_storage_quantity(&input) else {
11173 self.state.push_log("Enter a quantity (blank = all).");
11174 return Ok(());
11175 };
11176 if let Some(q) = qty_opt {
11177 if q > max_qty {
11178 self.state.push_log(format!("Only {max_qty} available."));
11179 return Ok(());
11180 }
11181 }
11182 self.state.market_ui_mode = MarketUiMode::ListPricingMode {
11183 source,
11184 pick_index,
11185 item_instance_id,
11186 template_id,
11187 label,
11188 quantity: qty_opt,
11189 max_qty,
11190 index: 0,
11191 };
11192 Ok(())
11193 }
11194 MarketUiMode::ListPricingMode {
11195 source,
11196 pick_index,
11197 item_instance_id,
11198 template_id,
11199 label,
11200 quantity,
11201 max_qty,
11202 index,
11203 } => {
11204 if index == 0 {
11205 if self
11206 .state
11207 .npc_market_dump_unit_estimate(&template_id)
11208 .is_none()
11209 {
11210 self.state
11211 .push_log("That item has no NPC value — use a fixed price instead.");
11212 return Ok(());
11213 }
11214 return self
11215 .submit_market_list_intent(
11216 source,
11217 item_instance_id,
11218 quantity,
11219 0,
11220 true,
11221 &label,
11222 )
11223 .await;
11224 }
11225 self.state.market_ui_mode = MarketUiMode::ListPrice {
11226 source,
11227 pick_index,
11228 item_instance_id,
11229 template_id,
11230 label,
11231 quantity,
11232 max_qty,
11233 input: String::new(),
11234 };
11235 Ok(())
11236 }
11237 MarketUiMode::ListPrice {
11238 source,
11239 item_instance_id,
11240 label,
11241 quantity,
11242 input,
11243 ..
11244 } => {
11245 let price = input.trim().parse::<u64>().unwrap_or(0);
11246 if price == 0 {
11247 self.state
11248 .push_log("Enter a unit price of at least 1 copper.");
11249 return Ok(());
11250 }
11251 self.submit_market_list_intent(
11252 source,
11253 item_instance_id,
11254 quantity,
11255 price,
11256 false,
11257 &label,
11258 )
11259 .await
11260 }
11261 }
11262 }
11263
11264 async fn submit_market_list_intent(
11265 &mut self,
11266 source: MarketListSourceKind,
11267 item_instance_id: uuid::Uuid,
11268 quantity: Option<u32>,
11269 unit_price_copper: u64,
11270 npc_price: bool,
11271 label: &str,
11272 ) -> anyhow::Result<()> {
11273 let Some(panel) = self.state.market_panel.clone() else {
11274 self.state.market_ui_mode = MarketUiMode::Browse;
11275 return Ok(());
11276 };
11277 let goods = match source {
11278 MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
11279 MarketListSourceKind::TownStorage { building_id } => {
11280 flatland_protocol::GoodsLocation::TownStorage { building_id }
11281 }
11282 };
11283 self.seq += 1;
11284 self.session
11285 .submit_intent(Intent::MarketList {
11286 entity_id: self.state.entity_id,
11287 npc_id: panel.npc_id,
11288 source: goods,
11289 item_instance_id,
11290 quantity,
11291 unit_price_copper,
11292 npc_price,
11293 seq: self.seq,
11294 })
11295 .await?;
11296 self.state.intents_sent += 1;
11297 if npc_price {
11298 self.state
11299 .push_log(format!("Listing {label} at NPC price…"));
11300 } else {
11301 self.state
11302 .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
11303 }
11304 self.state.market_ui_mode = MarketUiMode::Browse;
11305 Ok(())
11306 }
11307
11308 pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
11310 let return_to_verbs = self.state.npc_verb_target.is_some();
11311 self.close_shop_menu().await?;
11312 if return_to_verbs {
11313 self.state.show_npc_verb_menu = true;
11314 self.state.npc_verb_notice = None;
11315 }
11316 Ok(())
11317 }
11318
11319 pub fn shop_tab_toggle(&mut self) {
11320 self.state.shop_tab = match self.state.shop_tab {
11321 ShopTab::Buy => ShopTab::Sell,
11322 ShopTab::Sell => ShopTab::Buy,
11323 };
11324 self.state.shop_menu_index = 0;
11325 if self.state.shop_tab == ShopTab::Sell {
11326 self.state.shop_quantity_set_max();
11327 }
11328 self.state.clamp_shop_selection();
11329 }
11330
11331 pub fn shop_menu_move(&mut self, delta: i32) {
11332 self.state.shop_menu_move(delta);
11333 }
11334
11335 pub fn shop_quantity_adjust(&mut self, delta: i32) {
11336 self.state.shop_quantity_adjust(delta);
11337 }
11338
11339 pub fn shop_quantity_set_max(&mut self) {
11340 self.state.shop_quantity_set_max();
11341 }
11342
11343 pub fn shop_quantity_set_min(&mut self) {
11344 self.state.shop_quantity_set_min();
11345 }
11346
11347 pub fn toggle_quest_menu(&mut self) {
11348 self.state.show_quest_menu = !self.state.show_quest_menu;
11349 if self.state.show_quest_menu {
11350 self.state.quest_menu_index = 0;
11351 self.state.quest_withdraw_confirm = false;
11352 self.state.show_workers_menu = false;
11353 }
11354 }
11355
11356 pub fn toggle_workers_menu(&mut self) {
11357 if self.state.show_workers_menu {
11358 self.close_workers_menu_ui();
11359 } else {
11360 self.state.show_workers_menu = true;
11361 if self.state.workers_menu_index >= self.state.hired_workers.len() {
11363 self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11364 }
11365 self.state.show_quest_menu = false;
11366 self.close_worker_give_picker();
11367 self.close_worker_give_target_picker();
11368 self.close_worker_take_picker();
11369 self.close_worker_teach_picker();
11370 self.cancel_worker_rename();
11371 }
11372 }
11373
11374 pub fn close_workers_menu_ui(&mut self) {
11376 self.state.show_workers_menu = false;
11377 self.cancel_worker_dismissal();
11378 self.close_worker_give_picker();
11379 self.close_worker_give_target_picker();
11380 self.close_worker_take_picker();
11381 self.close_worker_teach_picker();
11382 self.cancel_worker_rename();
11383 }
11384
11385 pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
11387 let Some(idx) = self
11388 .state
11389 .hired_workers
11390 .iter()
11391 .position(|w| w.instance_id == instance_id)
11392 else {
11393 anyhow::bail!("worker not found");
11394 };
11395 let label = self.state.hired_workers[idx].label.clone();
11396 self.state.show_workers_menu = true;
11397 self.state.workers_menu_index = idx;
11398 self.state.show_quest_menu = false;
11399 self.close_worker_give_picker();
11400 self.close_worker_give_target_picker();
11401 self.close_worker_take_picker();
11402 self.close_worker_teach_picker();
11403 self.cancel_worker_rename();
11404 self.set_worker_attending(instance_id, true).await?;
11405 self.state
11406 .push_log(format!("Managing {label} — job paused while menu is open"));
11407 Ok(())
11408 }
11409
11410 pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
11412 self.close_workers_menu_ui();
11413 self.release_worker_attend().await
11414 }
11415
11416 async fn set_worker_attending(
11417 &mut self,
11418 instance_id: &str,
11419 attending: bool,
11420 ) -> anyhow::Result<()> {
11421 if attending {
11422 if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11423 return Ok(());
11424 }
11425 if let Some(prev) = self.state.attending_worker_instance_id.clone() {
11427 if prev != instance_id {
11428 self.send_attend_hired_worker(&prev, false).await?;
11429 }
11430 }
11431 self.send_attend_hired_worker(instance_id, true).await?;
11432 self.state.attending_worker_instance_id = Some(instance_id.to_string());
11433 } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11434 self.send_attend_hired_worker(instance_id, false).await?;
11435 self.state.attending_worker_instance_id = None;
11436 }
11437 Ok(())
11438 }
11439
11440 pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
11441 let Some(id) = self.state.attending_worker_instance_id.take() else {
11442 return Ok(());
11443 };
11444 self.send_attend_hired_worker(&id, false).await
11445 }
11446
11447 async fn send_attend_hired_worker(
11448 &mut self,
11449 worker_instance_id: &str,
11450 attending: bool,
11451 ) -> anyhow::Result<()> {
11452 self.seq += 1;
11453 self.session
11454 .submit_intent(Intent::AttendHiredWorker {
11455 entity_id: self.state.entity_id,
11456 worker_instance_id: worker_instance_id.to_string(),
11457 attending,
11458 seq: self.seq,
11459 })
11460 .await?;
11461 self.state.intents_sent += 1;
11462 Ok(())
11463 }
11464
11465 pub fn workers_menu_move(&mut self, delta: i32) {
11466 let n = self.state.hired_workers.len();
11467 if n == 0 {
11468 return;
11469 }
11470 let idx = self.state.workers_menu_index as i32;
11471 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
11472 }
11473
11474 pub fn toggle_workers_menu_compact(&mut self) {
11475 self.state.workers_menu_compact = !self.state.workers_menu_compact;
11476 let mut cfg = crate::client_config::ClientConfig::load();
11477 let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
11478 }
11479
11480 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
11481 let Some(worker) = self
11482 .state
11483 .hired_workers
11484 .get(self.state.workers_menu_index)
11485 .cloned()
11486 else {
11487 anyhow::bail!("no worker selected");
11488 };
11489 self.dismiss_worker_by_id(&worker.instance_id, &worker.label)
11490 .await
11491 }
11492
11493 pub fn request_worker_dismissal(&mut self) -> anyhow::Result<()> {
11495 let Some(worker) = self
11496 .state
11497 .hired_workers
11498 .get(self.state.workers_menu_index)
11499 .cloned()
11500 else {
11501 anyhow::bail!("no worker selected");
11502 };
11503 self.state.worker_dismiss_confirmation = Some(WorkerDismissConfirmation {
11504 worker_instance_id: worker.instance_id,
11505 worker_label: worker.label,
11506 });
11507 Ok(())
11508 }
11509
11510 pub fn cancel_worker_dismissal(&mut self) {
11511 self.state.worker_dismiss_confirmation = None;
11512 }
11513
11514 pub async fn confirm_worker_dismissal(&mut self) -> anyhow::Result<()> {
11515 let Some(confirm) = self.state.worker_dismiss_confirmation.clone() else {
11516 return Ok(());
11517 };
11518 self.dismiss_worker_by_id(&confirm.worker_instance_id, &confirm.worker_label)
11519 .await?;
11520 self.cancel_worker_dismissal();
11521 Ok(())
11522 }
11523
11524 async fn dismiss_worker_by_id(
11525 &mut self,
11526 worker_instance_id: &str,
11527 worker_label: &str,
11528 ) -> anyhow::Result<()> {
11529 self.seq += 1;
11530 self.session
11531 .submit_intent(Intent::DismissWorker {
11532 entity_id: self.state.entity_id,
11533 worker_instance_id: worker_instance_id.to_string(),
11534 seq: self.seq,
11535 })
11536 .await?;
11537 self.state.intents_sent += 1;
11538 self.state
11539 .hired_workers
11540 .retain(|w| w.instance_id != worker_instance_id);
11541 if self.state.workers_menu_index >= self.state.hired_workers.len() {
11542 self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11543 }
11544 self.state.push_log(format!("Dismissed {worker_label}"));
11545 Ok(())
11546 }
11547
11548 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
11549 let Some(worker) = self
11550 .state
11551 .hired_workers
11552 .get(self.state.workers_menu_index)
11553 .cloned()
11554 else {
11555 anyhow::bail!("no worker selected");
11556 };
11557 let mode = match worker.mode {
11558 flatland_protocol::WorkerModeView::Companion => "defender",
11559 flatland_protocol::WorkerModeView::Defender => "job_loop",
11560 flatland_protocol::WorkerModeView::JobLoop => "idle",
11561 flatland_protocol::WorkerModeView::Idle => "companion",
11562 };
11563 self.seq += 1;
11564 self.session
11565 .submit_intent(Intent::SetWorkerMode {
11566 entity_id: self.state.entity_id,
11567 worker_instance_id: worker.instance_id,
11568 mode: mode.into(),
11569 seq: self.seq,
11570 })
11571 .await?;
11572 self.state.intents_sent += 1;
11573 Ok(())
11574 }
11575
11576 pub async fn workers_deliver_selected_to_storage(&mut self) -> anyhow::Result<()> {
11577 let Some(worker) = self
11578 .state
11579 .hired_workers
11580 .get(self.state.workers_menu_index)
11581 .cloned()
11582 else {
11583 anyhow::bail!("no worker selected");
11584 };
11585 if !matches!(worker.mode, flatland_protocol::WorkerModeView::Companion) {
11586 anyhow::bail!("switch the worker to companion mode first");
11587 }
11588 if worker.step_label.starts_with("delivering to ")
11589 || worker.step_label == "returning to you"
11590 {
11591 anyhow::bail!("worker is already delivering to storage");
11592 }
11593 self.seq += 1;
11594 self.session
11595 .submit_intent(Intent::DeliverWorkerToNearestStorage {
11596 entity_id: self.state.entity_id,
11597 worker_instance_id: worker.instance_id.clone(),
11598 seq: self.seq,
11599 })
11600 .await?;
11601 self.state.intents_sent += 1;
11602 self.state.push_log(format!(
11603 "{} is delivering carried items to storage",
11604 worker.label
11605 ));
11606 Ok(())
11607 }
11608
11609 pub async fn workers_cancel_delivery_selected(&mut self) -> anyhow::Result<()> {
11610 let Some(worker) = self
11611 .state
11612 .hired_workers
11613 .get(self.state.workers_menu_index)
11614 .cloned()
11615 else {
11616 anyhow::bail!("no worker selected");
11617 };
11618 if !(worker.step_label.starts_with("delivering to ")
11619 || worker.step_label == "returning to you")
11620 {
11621 anyhow::bail!("worker has no active delivery");
11622 }
11623 self.seq += 1;
11624 self.session
11625 .submit_intent(Intent::CancelWorkerDelivery {
11626 entity_id: self.state.entity_id,
11627 worker_instance_id: worker.instance_id,
11628 seq: self.seq,
11629 })
11630 .await?;
11631 self.state.intents_sent += 1;
11632 self.state
11633 .push_log(format!("Canceled delivery for {}", worker.label));
11634 Ok(())
11635 }
11636
11637 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
11638 if self.state.hired_workers.is_empty() {
11639 return self.hire_worker_laborer().await;
11640 }
11641 self.workers_toggle_mode_selected().await
11642 }
11643
11644 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11647 let row = self
11648 .state
11649 .inventory_selected_row()
11650 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
11651 .clone();
11652 if row.from != flatland_protocol::InventoryLocation::Root {
11653 anyhow::bail!("select a carried item to give");
11654 }
11655 let Some(instance_id) = row.stack.item_instance_id else {
11656 anyhow::bail!("that stack can't be given");
11657 };
11658 let options = self.nearby_worker_give_targets();
11659 if options.is_empty() {
11660 anyhow::bail!(
11661 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
11662 );
11663 }
11664 let item_label = row
11665 .stack
11666 .display_name
11667 .as_deref()
11668 .unwrap_or(&row.stack.template_id)
11669 .to_string();
11670 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
11671 item_instance_id: instance_id,
11672 item_label,
11673 quantity: None,
11674 options,
11675 });
11676 self.state.worker_give_target_picker_index = 0;
11677 self.state.show_worker_give_target_picker = true;
11678 self.state.show_inventory_menu = false;
11680 Ok(())
11681 }
11682
11683 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
11685 let (px, py, _) = self.state.player_position_with_z();
11686 let mut options: Vec<WorkerGiveTargetOption> = self
11687 .state
11688 .hired_workers
11689 .iter()
11690 .filter_map(|w| {
11691 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
11692 if dist > WORKER_GIVE_RANGE_M {
11693 return None;
11694 }
11695 Some(WorkerGiveTargetOption {
11696 instance_id: w.instance_id.clone(),
11697 label: w.label.clone(),
11698 distance_m: dist,
11699 })
11700 })
11701 .collect();
11702 options.sort_by(|a, b| {
11703 a.distance_m
11704 .partial_cmp(&b.distance_m)
11705 .unwrap_or(std::cmp::Ordering::Equal)
11706 });
11707 options
11708 }
11709
11710 pub fn close_worker_give_target_picker(&mut self) {
11711 self.state.show_worker_give_target_picker = false;
11712 self.state.worker_give_target_picker = None;
11713 self.state.worker_give_target_picker_index = 0;
11714 }
11715
11716 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
11717 let Some(picker) = &self.state.worker_give_target_picker else {
11718 return;
11719 };
11720 let n = picker.options.len();
11721 if n == 0 {
11722 return;
11723 }
11724 let idx = self.state.worker_give_target_picker_index as i32;
11725 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11726 }
11727
11728 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11729 let Some(picker) = self.state.worker_give_target_picker.clone() else {
11730 anyhow::bail!("give target picker not open");
11731 };
11732 let Some(opt) = picker
11733 .options
11734 .get(self.state.worker_give_target_picker_index)
11735 .cloned()
11736 else {
11737 anyhow::bail!("no worker selected");
11738 };
11739 let Some(worker) = self
11740 .state
11741 .hired_workers
11742 .iter()
11743 .find(|w| w.instance_id == opt.instance_id)
11744 .cloned()
11745 else {
11746 self.close_worker_give_target_picker();
11747 anyhow::bail!("worker no longer hired");
11748 };
11749 self.give_item_to_worker(
11750 &worker.instance_id,
11751 &worker.label,
11752 worker.x,
11753 worker.y,
11754 picker.item_instance_id,
11755 &picker.item_label,
11756 picker.quantity,
11757 )
11758 .await?;
11759 self.close_worker_give_target_picker();
11760 Ok(())
11761 }
11762
11763 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
11765 self.open_worker_give_target_picker()
11766 }
11767
11768 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
11770 let Some(worker) = self
11771 .state
11772 .hired_workers
11773 .get(self.state.workers_menu_index)
11774 .cloned()
11775 else {
11776 anyhow::bail!("select a hired worker first");
11777 };
11778 let (px, py, _) = self.state.player_position_with_z();
11779 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11780 if dist > WORKER_GIVE_RANGE_M {
11781 anyhow::bail!(
11782 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
11783 worker.label
11784 );
11785 }
11786 let options = self.state.giveable_inventory_options();
11787 if options.is_empty() {
11788 anyhow::bail!("nothing in inventory to give");
11789 }
11790 self.state.worker_give_picker = Some(WorkerGivePicker {
11791 worker_instance_id: worker.instance_id,
11792 worker_label: worker.label,
11793 options,
11794 });
11795 self.state.worker_give_picker_index = 0;
11796 self.state.show_worker_give_picker = true;
11797 Ok(())
11798 }
11799
11800 pub fn close_worker_give_picker(&mut self) {
11801 self.state.show_worker_give_picker = false;
11802 self.state.worker_give_picker = None;
11803 self.state.worker_give_picker_index = 0;
11804 }
11805
11806 pub fn worker_give_picker_move(&mut self, delta: i32) {
11807 let Some(picker) = &self.state.worker_give_picker else {
11808 return;
11809 };
11810 let n = picker.options.len();
11811 if n == 0 {
11812 return;
11813 }
11814 let idx = self.state.worker_give_picker_index as i32;
11815 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11816 }
11817
11818 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
11820 let Some(picker) = self.state.worker_give_picker.clone() else {
11821 anyhow::bail!("give picker not open");
11822 };
11823 let Some(opt) = picker
11824 .options
11825 .get(self.state.worker_give_picker_index)
11826 .cloned()
11827 else {
11828 anyhow::bail!("no item selected");
11829 };
11830 let Some(worker) = self
11831 .state
11832 .hired_workers
11833 .iter()
11834 .find(|w| w.instance_id == picker.worker_instance_id)
11835 .cloned()
11836 else {
11837 self.close_worker_give_picker();
11838 anyhow::bail!("worker no longer hired");
11839 };
11840 self.give_item_to_worker(
11841 &worker.instance_id,
11842 &worker.label,
11843 worker.x,
11844 worker.y,
11845 opt.item_instance_id,
11846 &opt.label,
11847 None,
11848 )
11849 .await?;
11850 let options = self.state.giveable_inventory_options();
11852 if options.is_empty() {
11853 self.close_worker_give_picker();
11854 } else {
11855 self.state.worker_give_picker = Some(WorkerGivePicker {
11856 worker_instance_id: picker.worker_instance_id,
11857 worker_label: picker.worker_label,
11858 options,
11859 });
11860 if self.state.worker_give_picker_index
11861 >= self
11862 .state
11863 .worker_give_picker
11864 .as_ref()
11865 .map(|p| p.options.len())
11866 .unwrap_or(0)
11867 {
11868 self.state.worker_give_picker_index = self
11869 .state
11870 .worker_give_picker
11871 .as_ref()
11872 .map(|p| p.options.len().saturating_sub(1))
11873 .unwrap_or(0);
11874 }
11875 }
11876 Ok(())
11877 }
11878
11879 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11881 let Some(worker) = self
11882 .state
11883 .hired_workers
11884 .get(self.state.workers_menu_index)
11885 .cloned()
11886 else {
11887 anyhow::bail!("select a hired worker first");
11888 };
11889 let (px, py, _) = self.state.player_position_with_z();
11890 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11891 if dist > WORKER_GIVE_RANGE_M {
11892 anyhow::bail!(
11893 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
11894 worker.label
11895 );
11896 }
11897 let options = self.state.teachable_blueprint_options(&worker);
11898 if options.is_empty() {
11899 anyhow::bail!("no recipes you know that {} still needs", worker.label);
11900 }
11901 self.state.worker_teach_picker = Some(WorkerTeachPicker {
11902 worker_instance_id: worker.instance_id,
11903 worker_label: worker.label,
11904 worker_level: worker.level,
11905 options,
11906 });
11907 self.state.worker_teach_picker_index = 0;
11908 self.state.show_worker_teach_picker = true;
11909 Ok(())
11910 }
11911
11912 pub fn close_worker_teach_picker(&mut self) {
11913 self.state.show_worker_teach_picker = false;
11914 self.state.worker_teach_picker = None;
11915 self.state.worker_teach_picker_index = 0;
11916 }
11917
11918 pub fn worker_teach_picker_move(&mut self, delta: i32) {
11919 let Some(picker) = &self.state.worker_teach_picker else {
11920 return;
11921 };
11922 let n = picker.options.len();
11923 if n == 0 {
11924 return;
11925 }
11926 let idx = self.state.worker_teach_picker_index as i32;
11927 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11928 }
11929
11930 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11931 let Some(picker) = self.state.worker_teach_picker.clone() else {
11932 anyhow::bail!("teach picker not open");
11933 };
11934 let Some(opt) = picker
11935 .options
11936 .get(self.state.worker_teach_picker_index)
11937 .cloned()
11938 else {
11939 anyhow::bail!("nothing selected");
11940 };
11941 if !opt.level_ok {
11942 anyhow::bail!(
11943 "{} needs level {} (is level {})",
11944 picker.worker_label,
11945 opt.min_level,
11946 opt.worker_level
11947 );
11948 }
11949 if !opt.can_afford {
11950 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
11951 }
11952 let Some(worker) = self
11953 .state
11954 .hired_workers
11955 .iter()
11956 .find(|w| w.instance_id == picker.worker_instance_id)
11957 .cloned()
11958 else {
11959 anyhow::bail!("worker gone");
11960 };
11961 let (px, py, _) = self.state.player_position_with_z();
11962 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11963 if dist > WORKER_GIVE_RANGE_M {
11964 anyhow::bail!("worker {} too far — stand next to them", worker.label);
11965 }
11966 self.seq += 1;
11967 self.session
11968 .submit_intent(Intent::TeachWorkerBlueprint {
11969 entity_id: self.state.entity_id,
11970 worker_instance_id: picker.worker_instance_id.clone(),
11971 blueprint_id: opt.blueprint_id.clone(),
11972 seq: self.seq,
11973 })
11974 .await?;
11975 self.state.intents_sent += 1;
11976 self.state.push_log(format!(
11977 "Teaching {} to {} ({} cp)",
11978 opt.label, picker.worker_label, opt.cost_copper
11979 ));
11980 self.close_worker_teach_picker();
11981 Ok(())
11982 }
11983
11984 async fn give_item_to_worker(
11985 &mut self,
11986 worker_instance_id: &str,
11987 worker_label: &str,
11988 worker_x: f32,
11989 worker_y: f32,
11990 item_instance_id: uuid::Uuid,
11991 item_label: &str,
11992 quantity: Option<u32>,
11993 ) -> anyhow::Result<()> {
11994 let (px, py, _) = self.state.player_position_with_z();
11995 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
11996 if dist > WORKER_GIVE_RANGE_M {
11997 anyhow::bail!("worker {worker_label} too far — stand next to them");
11998 }
11999 self.seq += 1;
12000 self.session
12001 .submit_intent(Intent::GiveWorkerItem {
12002 entity_id: self.state.entity_id,
12003 worker_instance_id: worker_instance_id.to_string(),
12004 item_instance_id,
12005 quantity,
12006 seq: self.seq,
12007 })
12008 .await?;
12009 self.state.intents_sent += 1;
12010 self.state
12011 .remove_carried_instance(item_instance_id, quantity);
12012 self.state
12013 .push_log(format!("Gave {item_label} to {worker_label}"));
12014 Ok(())
12015 }
12016
12017 pub async fn equip_item_on_worker(
12021 &mut self,
12022 worker_instance_id: &str,
12023 item_instance_id: uuid::Uuid,
12024 slot: &str,
12025 ) -> anyhow::Result<()> {
12026 let Some(worker) = self
12027 .state
12028 .hired_workers
12029 .iter()
12030 .find(|worker| worker.instance_id == worker_instance_id)
12031 .cloned()
12032 else {
12033 anyhow::bail!("worker not found");
12034 };
12035 let (px, py, _) = self.state.player_position_with_z();
12036 if (worker.x - px).hypot(worker.y - py) > WORKER_GIVE_RANGE_M {
12037 anyhow::bail!("worker {} too far — stand next to them", worker.label);
12038 }
12039 self.seq += 1;
12040 self.session
12041 .submit_intent(Intent::EquipWorkerItem {
12042 entity_id: self.state.entity_id,
12043 worker_instance_id: worker.instance_id.clone(),
12044 item_instance_id,
12045 slot: slot.to_string(),
12046 seq: self.seq,
12047 })
12048 .await?;
12049 self.state.intents_sent += 1;
12050 self.state
12051 .push_log(format!("Equipped {slot} on {}", worker.label));
12052 Ok(())
12053 }
12054
12055 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
12057 let Some(worker) = self
12058 .state
12059 .hired_workers
12060 .get(self.state.workers_menu_index)
12061 .cloned()
12062 else {
12063 anyhow::bail!("select a hired worker first");
12064 };
12065 let (px, py, _) = self.state.player_position_with_z();
12066 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
12067 if dist > WORKER_GIVE_RANGE_M {
12068 anyhow::bail!(
12069 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
12070 worker.label
12071 );
12072 }
12073 let options = Self::worker_inventory_options(&worker);
12074 if options.is_empty() {
12075 anyhow::bail!("{} isn't carrying anything", worker.label);
12076 }
12077 let initial_qty = options
12078 .first()
12079 .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
12080 .unwrap_or(1);
12081 self.state.worker_take_picker = Some(WorkerTakePicker {
12082 worker_instance_id: worker.instance_id,
12083 worker_label: worker.label,
12084 options,
12085 quantity: initial_qty,
12086 });
12087 self.state.worker_take_picker_index = 0;
12088 self.state.show_worker_take_picker = true;
12089 Ok(())
12090 }
12091
12092 fn worker_inventory_options(
12093 worker: &flatland_protocol::HiredWorkerView,
12094 ) -> Vec<WorkerGiveOption> {
12095 worker
12096 .inventory
12097 .iter()
12098 .filter_map(|stack| {
12099 let item_instance_id = stack.item_instance_id?;
12100 let label = stack
12101 .display_name
12102 .clone()
12103 .unwrap_or_else(|| stack.template_id.clone());
12104 let label = if stack.quantity > 1 {
12105 format!("{label} ×{}", stack.quantity)
12106 } else {
12107 label
12108 };
12109 Some(WorkerGiveOption {
12110 item_instance_id,
12111 label,
12112 quantity: stack.quantity,
12113 template_id: stack.template_id.clone(),
12114 })
12115 })
12116 .collect()
12117 }
12118
12119 pub fn close_worker_take_picker(&mut self) {
12120 self.state.show_worker_take_picker = false;
12121 self.state.worker_take_picker = None;
12122 self.state.worker_take_picker_index = 0;
12123 }
12124
12125 pub fn worker_take_picker_move(&mut self, delta: i32) {
12126 let Some(picker) = &self.state.worker_take_picker else {
12127 return;
12128 };
12129 let n = picker.options.len();
12130 if n == 0 {
12131 return;
12132 }
12133 let idx = self.state.worker_take_picker_index as i32;
12134 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
12135 self.clamp_worker_take_quantity();
12136 }
12137
12138 pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
12139 let Some(picker) = &mut self.state.worker_take_picker else {
12140 return;
12141 };
12142 let max = picker
12143 .options
12144 .get(self.state.worker_take_picker_index)
12145 .map(|o| o.quantity.max(1))
12146 .unwrap_or(1);
12147 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
12148 picker.quantity = next as u32;
12149 }
12150
12151 pub fn worker_take_picker_set_quantity_max(&mut self) {
12152 let Some(picker) = &mut self.state.worker_take_picker else {
12153 return;
12154 };
12155 let max = picker
12156 .options
12157 .get(self.state.worker_take_picker_index)
12158 .map(|o| o.quantity.max(1))
12159 .unwrap_or(1);
12160 picker.quantity = max;
12161 }
12162
12163 pub fn worker_take_picker_set_quantity_min(&mut self) {
12164 let Some(picker) = &mut self.state.worker_take_picker else {
12165 return;
12166 };
12167 picker.quantity = 1;
12168 self.clamp_worker_take_quantity();
12169 }
12170
12171 fn clamp_worker_take_quantity(&mut self) {
12172 let Some(picker) = &mut self.state.worker_take_picker else {
12173 return;
12174 };
12175 let max = picker
12176 .options
12177 .get(self.state.worker_take_picker_index)
12178 .map(|o| o.quantity.max(1))
12179 .unwrap_or(1);
12180 if picker.quantity == 0 || picker.quantity > max {
12181 picker.quantity = if max > 1 { 1 } else { max };
12182 }
12183 }
12184
12185 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
12186 let Some(picker) = self.state.worker_take_picker.clone() else {
12187 anyhow::bail!("take picker not open");
12188 };
12189 let Some(opt) = picker
12190 .options
12191 .get(self.state.worker_take_picker_index)
12192 .cloned()
12193 else {
12194 anyhow::bail!("no item selected");
12195 };
12196 let Some(worker) = self
12197 .state
12198 .hired_workers
12199 .iter()
12200 .find(|w| w.instance_id == picker.worker_instance_id)
12201 .cloned()
12202 else {
12203 self.close_worker_take_picker();
12204 anyhow::bail!("worker no longer hired");
12205 };
12206 let qty = picker.quantity.clamp(1, opt.quantity.max(1));
12207 let intent_qty = if qty >= opt.quantity { None } else { Some(qty) };
12208 self.take_item_from_worker(
12209 &worker.instance_id,
12210 &worker.label,
12211 worker.x,
12212 worker.y,
12213 opt.item_instance_id,
12214 &opt.label,
12215 intent_qty,
12216 )
12217 .await?;
12218 Ok(())
12221 }
12222
12223 async fn take_item_from_worker(
12224 &mut self,
12225 worker_instance_id: &str,
12226 worker_label: &str,
12227 worker_x: f32,
12228 worker_y: f32,
12229 item_instance_id: uuid::Uuid,
12230 item_label: &str,
12231 quantity: Option<u32>,
12232 ) -> anyhow::Result<()> {
12233 let (px, py, _) = self.state.player_position_with_z();
12234 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
12235 if dist > WORKER_GIVE_RANGE_M {
12236 anyhow::bail!("worker {worker_label} too far — stand next to them");
12237 }
12238 self.seq += 1;
12239 self.session
12240 .submit_intent(Intent::TakeWorkerItem {
12241 entity_id: self.state.entity_id,
12242 worker_instance_id: worker_instance_id.to_string(),
12243 item_instance_id,
12244 quantity,
12245 seq: self.seq,
12246 })
12247 .await?;
12248 self.state.intents_sent += 1;
12249 let qty_note = quantity.map(|q| format!(" ×{q}")).unwrap_or_default();
12250 self.state.push_log(format!(
12251 "Taking {item_label}{qty_note} from {worker_label}…"
12252 ));
12253 Ok(())
12254 }
12255
12256 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
12257 if let Some(since) = self.state.pending_worker_hire_since {
12258 if since.elapsed() < WORKER_HIRE_PENDING_TIMEOUT {
12259 anyhow::bail!("hire request still pending — wait for the worker roster update");
12260 }
12261 self.state.pending_worker_hire_since = None;
12262 }
12263 if !self.state.has_worker_lodging() {
12264 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
12265 }
12266 self.seq += 1;
12267 self.session
12268 .submit_intent(Intent::HireWorker {
12269 entity_id: self.state.entity_id,
12270 def_id: "worker_laborer".into(),
12271 wage_copper_per_interval: 8,
12272 lodging_container_id: None,
12273 job_yaml: None,
12274 seq: self.seq,
12275 })
12276 .await?;
12277 self.state.intents_sent += 1;
12278 self.state.pending_worker_hire_since = Some(Instant::now());
12279 Ok(())
12280 }
12281
12282 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
12283 let Some(worker) = self
12284 .state
12285 .hired_workers
12286 .get(self.state.workers_menu_index)
12287 .cloned()
12288 else {
12289 anyhow::bail!("select a hired worker first");
12290 };
12291 let lodging = worker.lodging_container_id.clone().or_else(|| {
12292 crate::worker_route_editor::owned_lodging_container_ids(
12293 &self.state.placed_containers,
12294 self.state.character_id,
12295 )
12296 .into_iter()
12297 .next()
12298 .map(|(id, _)| id)
12299 });
12300 let label = worker.label.clone();
12301 let editor = if let Some(route) = &worker.route {
12302 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
12303 worker.instance_id,
12304 worker.label,
12305 route,
12306 lodging,
12307 )
12308 } else {
12309 crate::worker_route_editor::WorkerRouteEditorState::new(
12310 worker.instance_id,
12311 worker.label,
12312 lodging,
12313 )
12314 };
12315 self.state.worker_route_editor = Some(editor);
12316 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12317 if let Some(collapsed) =
12318 crate::client_config::ClientConfig::load().worker_route_panel_collapsed
12319 {
12320 ed.panel_collapsed = collapsed;
12321 }
12322 }
12323 self.state.show_workers_menu = false;
12324 self.state.push_log(format!(
12325 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
12326 ));
12327 Ok(())
12328 }
12329
12330 pub fn close_worker_route_editor(&mut self) {
12331 self.state.worker_route_editor = None;
12332 }
12333
12334 pub fn worker_route_editor_toggle_panel(&mut self) {
12335 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12336 ed.toggle_panel_collapsed();
12337 let collapsed = ed.panel_collapsed;
12338 let mut cfg = crate::client_config::ClientConfig::load();
12339 let _ = cfg.save_worker_route_panel_collapsed(collapsed);
12340 }
12341 }
12342
12343 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
12344 let n = {
12345 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12346 return;
12347 };
12348 ed.append_waypoint(x, y, z);
12349 ed.stop_count()
12350 };
12351 self.state
12352 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
12353 }
12354
12355 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
12358 let (px, py, _) = self.state.player_position_with_z();
12359 let inside = self.state.effective_inside_building();
12360 crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
12361 &self.state.placed_containers,
12362 &self.state.buildings,
12363 self.state.character_id,
12364 px,
12365 py,
12366 &self.state.hired_workers,
12367 inside.as_deref(),
12368 )
12369 }
12370
12371 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
12372 self.state.route_editor_node_candidates()
12373 }
12374
12375 fn re_open_harvest_picker(&mut self, index: usize, picked: std::collections::BTreeSet<String>) {
12376 use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
12377 let nodes = self.state.route_editor_node_candidates();
12378 let index = if nodes.is_empty() {
12379 ROUTE_PICKER_DONE_ROW
12380 } else {
12381 index.max(1).min(nodes.len())
12382 };
12383 self.re_open_sheet(S::HarvestPicker {
12384 index,
12385 picked,
12386 nodes,
12387 });
12388 }
12389
12390 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
12391 let (px, py, _) = self.state.player_position_with_z();
12392 let templates = self.re_template_candidates();
12393 crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py, &templates)
12394 }
12395
12396 fn re_template_candidates(&self) -> Vec<String> {
12397 let mut extra = Vec::new();
12398 if let Some(ed) = self.state.worker_route_editor.as_ref() {
12399 for stop in &ed.stops {
12400 match stop {
12401 crate::worker_route_editor::WorkerRouteStop::DepositAt {
12402 filter: Some(filter),
12403 ..
12404 } => extra.extend(filter.iter().cloned()),
12405 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. }
12406 | crate::worker_route_editor::WorkerRouteStop::ListOnMarket {
12407 template, ..
12408 } => {
12409 extra.push(template.clone());
12410 }
12411 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
12412 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint)
12413 {
12414 extra.push(bp.output.clone());
12415 for input in &bp.inputs {
12416 extra.push(input.template_id.clone());
12417 }
12418 }
12419 }
12420 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
12421 for it in items {
12422 extra.push(it.template.clone());
12423 }
12424 }
12425 _ => {}
12426 }
12427 }
12428 if let Some(worker) = self
12430 .state
12431 .hired_workers
12432 .iter()
12433 .find(|w| w.instance_id == ed.worker_instance_id)
12434 {
12435 for recipe in &worker.known_blueprint_ids {
12436 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
12437 extra.push(bp.output.clone());
12438 }
12439 }
12440 for stack in &worker.inventory {
12441 if !stack.template_id.is_empty() && stack.quantity > 0 {
12442 extra.push(stack.template_id.clone());
12443 }
12444 }
12445 }
12446 }
12447 crate::worker_route_editor::route_item_template_candidates(
12448 &self.state.placed_containers,
12449 self.state.character_id,
12450 &self.state.inventory,
12451 &self.state.blueprints,
12452 if self.state.harvest_route_nodes.is_empty() {
12453 &self.state.resource_nodes
12454 } else {
12455 &self.state.harvest_route_nodes
12456 },
12457 &extra,
12458 Some(&self.state.item_catalog),
12459 )
12460 }
12461
12462 fn re_blueprint_ids(&self) -> Vec<String> {
12463 let worker_known: Option<&[String]> = self
12464 .state
12465 .worker_route_editor
12466 .as_ref()
12467 .and_then(|ed| {
12468 self.state
12469 .hired_workers
12470 .iter()
12471 .find(|w| w.instance_id == ed.worker_instance_id)
12472 })
12473 .map(|w| w.known_blueprint_ids.as_slice());
12474 crate::worker_route_editor::worker_craft_blueprint_ids(&self.state.blueprints, worker_known)
12475 }
12476
12477 fn re_bed_candidates(&self) -> Vec<(String, String)> {
12478 crate::worker_route_editor::owned_lodging_container_ids(
12479 &self.state.placed_containers,
12480 self.state.character_id,
12481 )
12482 }
12483
12484 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
12485 self.state
12486 .placed_containers
12487 .iter()
12488 .find(|c| c.id == container_id)
12489 .map(|c| c.contents.clone())
12490 .unwrap_or_default()
12491 }
12492
12493 fn re_sheet_supports_filter(&self) -> bool {
12496 use crate::worker_route_editor::RouteEditorSheet as S;
12497 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12498 matches!(
12499 ed.sheet,
12500 S::HarvestPicker { .. }
12501 | S::SellItem { .. }
12502 | S::MarketListItem { .. }
12503 | S::DepositFilter { .. }
12504 | S::WithdrawItems { .. }
12505 | S::WithdrawContainers { .. }
12506 | S::DepositContainers { .. }
12507 | S::SellNpcs { .. }
12508 | S::CraftBlueprint { .. }
12509 | S::BedPicker { .. }
12510 )
12511 })
12512 }
12513
12514 pub fn re_sheet_row_visible(&self, row: usize) -> bool {
12516 use crate::worker_route_editor::{
12517 harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
12518 ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12519 };
12520 let Some(ed) = self.state.worker_route_editor.as_ref() else {
12521 return false;
12522 };
12523 let filter = &ed.sheet_filter;
12524 match &ed.sheet {
12525 S::HarvestPicker { nodes, .. } => harvest_picker_row_matches(nodes, row, filter),
12526 S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12527 if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
12528 return true;
12529 }
12530 let slot = row.saturating_sub(2);
12531 templates.get(slot).is_some_and(|t| {
12532 let label = self.state.template_display_name(t);
12533 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12534 })
12535 }
12536 S::DepositFilter { rows, .. } => {
12537 if row >= rows.len() {
12538 return true;
12539 }
12540 rows.get(row).is_some_and(|(t, _)| {
12541 let label = self.state.template_display_name(t);
12542 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12543 })
12544 }
12545 S::WithdrawItems { lines, .. } => {
12546 if row >= lines.len() {
12547 return true;
12548 }
12549 lines.get(row).is_some_and(|l| {
12550 let label = self.state.template_display_name(&l.template);
12551 list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
12552 })
12553 }
12554 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12555 self.re_container_candidates().get(row).is_some_and(|c| {
12556 list_filter_row_matches(
12557 filter,
12558 Some(c.dist),
12559 &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
12560 )
12561 })
12562 }
12563 S::SellNpcs { .. } => {
12564 if row == 0 {
12565 return true;
12566 }
12567 self.re_npc_candidates().get(row - 1).is_some_and(|n| {
12568 list_filter_row_matches(
12569 filter,
12570 Some(n.dist),
12571 &[n.label.as_str(), n.id.as_str()],
12572 )
12573 })
12574 }
12575 S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
12576 let label = self
12577 .state
12578 .blueprints
12579 .iter()
12580 .find(|b| &b.id == id)
12581 .map(|b| {
12582 if b.label.is_empty() {
12583 id.as_str()
12584 } else {
12585 b.label.as_str()
12586 }
12587 })
12588 .unwrap_or(id.as_str());
12589 list_filter_row_matches(filter, None, &[id.as_str(), label])
12590 }),
12591 S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
12592 list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
12593 }),
12594 _ => true,
12595 }
12596 }
12597
12598 fn re_sheet_clamp_index(&mut self) {
12599 let count = self.re_sheet_row_count();
12600 if count == 0 {
12601 return;
12602 }
12603 let cur = self.re_sheet_index();
12604 if self.re_sheet_row_visible(cur) {
12605 return;
12606 }
12607 for offset in 1..count {
12608 if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
12609 self.re_sheet_set_index(cur + offset);
12610 return;
12611 }
12612 if cur >= offset && self.re_sheet_row_visible(cur - offset) {
12613 self.re_sheet_set_index(cur - offset);
12614 return;
12615 }
12616 }
12617 }
12618
12619 fn re_sheet_set_index(&mut self, index: usize) {
12620 use crate::worker_route_editor::RouteEditorSheet as S;
12621 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12622 return;
12623 };
12624 match &mut ed.sheet {
12625 S::AddMenu { index: slot }
12626 | S::WaypointMenu { index: slot }
12627 | S::HarvestPicker { index: slot, .. }
12628 | S::WithdrawContainers { index: slot }
12629 | S::DepositContainers { index: slot }
12630 | S::SellNpcs { index: slot }
12631 | S::CraftBlueprint { index: slot }
12632 | S::BedPicker { index: slot }
12633 | S::FarmPlotPicker { index: slot, .. }
12634 | S::FarmPlantSeed { index: slot, .. }
12635 | S::WithdrawItems { index: slot, .. }
12636 | S::DepositFilter { index: slot, .. }
12637 | S::SellItem { index: slot, .. }
12638 | S::MarketListItem { index: slot, .. } => *slot = index,
12639 _ => {}
12640 }
12641 }
12642
12643 pub fn re_focus_sheet_filter(&mut self) {
12644 if !self.re_sheet_supports_filter() {
12645 return;
12646 }
12647 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12648 ed.sheet_filter_focused = true;
12649 }
12650 }
12651
12652 pub fn re_blur_sheet_filter_keep_text(&mut self) {
12653 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12654 return;
12655 };
12656 if !ed.sheet_filter_focused {
12657 return;
12658 }
12659 ed.sheet_filter_focused = false;
12660 self.re_sheet_clamp_index();
12661 }
12662
12663 pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
12664 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12665 return false;
12666 };
12667 if ed.sheet_filter_focused {
12668 ed.sheet_filter_focused = false;
12669 self.re_sheet_clamp_index();
12670 return true;
12671 }
12672 if !ed.sheet_filter.is_empty() {
12673 ed.sheet_filter.clear();
12674 self.re_sheet_clamp_index();
12675 return true;
12676 }
12677 false
12678 }
12679
12680 pub fn re_append_sheet_filter_char(&mut self, ch: char) {
12681 if ch.is_control() {
12682 return;
12683 }
12684 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12685 return;
12686 };
12687 if !ed.sheet_filter_focused {
12688 return;
12689 }
12690 ed.sheet_filter.push(ch);
12691 self.re_sheet_set_index(0);
12692 self.re_sheet_clamp_index();
12693 }
12694
12695 pub fn re_sheet_filter_backspace(&mut self) {
12696 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12697 return;
12698 };
12699 if !ed.sheet_filter_focused {
12700 return;
12701 }
12702 ed.sheet_filter.pop();
12703 self.re_sheet_set_index(0);
12704 self.re_sheet_clamp_index();
12705 }
12706
12707 pub fn re_sheet_row_count(&self) -> usize {
12709 use crate::worker_route_editor::{
12710 harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
12711 };
12712 let Some(ed) = self.state.worker_route_editor.as_ref() else {
12713 return 0;
12714 };
12715 match &ed.sheet {
12716 S::Stops => ed.stops.len(),
12717 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
12718 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
12719 S::WaypointMapPick => 0,
12720 S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
12721 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12722 self.re_container_candidates().len()
12723 }
12724 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, .. } => {
12728 sell_item_picker_row_count(templates.len())
12729 }
12730 S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
12731 S::WaitEntry { .. } => 1,
12732 S::BedPicker { .. } => self.re_bed_candidates().len(),
12733 S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
12734 S::FarmPlantSeed { seeds, .. } => seeds.len(),
12735 }
12736 }
12737
12738 pub fn re_sheet_index(&self) -> usize {
12740 use crate::worker_route_editor::RouteEditorSheet as S;
12741 let Some(ed) = self.state.worker_route_editor.as_ref() else {
12742 return 0;
12743 };
12744 match &ed.sheet {
12745 S::AddMenu { index }
12746 | S::WaypointMenu { index }
12747 | S::HarvestPicker { index, .. }
12748 | S::WithdrawContainers { index }
12749 | S::DepositContainers { index }
12750 | S::SellNpcs { index }
12751 | S::CraftBlueprint { index }
12752 | S::BedPicker { index }
12753 | S::FarmPlotPicker { index, .. }
12754 | S::FarmPlantSeed { index, .. }
12755 | S::WithdrawItems { index, .. }
12756 | S::DepositFilter { index, .. }
12757 | S::SellItem { index, .. }
12758 | S::MarketListItem { index, .. } => *index,
12759 _ => 0,
12760 }
12761 }
12762
12763 pub fn re_sheet_move(&mut self, delta: i32) {
12765 let count = self.re_sheet_row_count();
12766 if count == 0 {
12767 return;
12768 }
12769 let cur = self.re_sheet_index();
12770 let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
12771 self.re_sheet_set_index(next);
12772 }
12773
12774 pub fn re_sheet_page(&mut self, pages: i32) {
12775 let count = self.re_sheet_row_count();
12776 if count == 0 {
12777 return;
12778 }
12779 let cur = self.re_sheet_index();
12780 let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
12781 self.re_sheet_set_index(next);
12782 }
12783
12784 pub fn re_sheet_adjust(&mut self, delta: i32) {
12786 use crate::worker_route_editor::RouteEditorSheet as S;
12787 let index = self.re_sheet_index();
12788 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12789 return;
12790 };
12791 match &mut ed.sheet {
12792 S::WithdrawItems { lines, .. } => {
12793 if let Some(line) = lines.get_mut(index) {
12794 line.adjust_qty(delta);
12795 }
12796 }
12797 S::WaitEntry { ticks } => {
12798 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
12799 }
12800 _ => {}
12801 }
12802 }
12803
12804 pub fn re_sheet_back(&mut self) {
12805 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12806 return;
12807 };
12808 use crate::worker_route_editor::RouteEditorSheet as S;
12809 let was_editing = ed.editing_index.is_some();
12810 let from_top_picker = matches!(
12811 ed.sheet,
12812 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
12813 );
12814 ed.sheet_back();
12815 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
12816 self.state
12818 .push_log("Route: left edit sheet — press s to save current stops".to_string());
12819 }
12820 }
12821
12822 pub fn re_at_root_sheet(&self) -> bool {
12824 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12825 matches!(
12826 ed.sheet,
12827 crate::worker_route_editor::RouteEditorSheet::Stops
12828 )
12829 })
12830 }
12831
12832 pub fn re_open_add_menu(&mut self) {
12833 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12834 ed.open_add_menu();
12835 }
12836 }
12837
12838 pub fn re_open_bed_picker(&mut self) {
12839 let beds = self.re_bed_candidates();
12840 if beds.is_empty() {
12841 self.state
12842 .push_log("Route: place a camp bed first".to_string());
12843 return;
12844 }
12845 let current = self
12846 .state
12847 .worker_route_editor
12848 .as_ref()
12849 .and_then(|ed| ed.lodging_container_id.clone());
12850 let index = current
12851 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
12852 .unwrap_or(0);
12853 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
12854 }
12855
12856 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
12857 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12858 ed.open_sheet(sheet);
12859 }
12860 }
12861
12862 fn re_confirm_stop(&mut self, stop: crate::worker_route_editor::WorkerRouteStop, what: String) {
12864 let appended = self
12865 .state
12866 .worker_route_editor
12867 .as_mut()
12868 .is_some_and(|ed| ed.confirm_stop(stop));
12869 if appended {
12870 self.state.push_log(format!("Route: + {what}"));
12871 } else {
12872 self.state
12873 .push_log(format!("Route: {what} already in route — selected it"));
12874 }
12875 }
12876
12877 fn re_open_withdraw_items(&mut self, container_id: String) {
12878 use crate::worker_route_editor::{
12879 RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12880 };
12881 let contents = self.re_container_contents(&container_id);
12882 let existing = self
12886 .state
12887 .worker_route_editor
12888 .as_ref()
12889 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12890 .and_then(|stop| match stop {
12891 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
12892 _ => None,
12893 })
12894 .unwrap_or_default();
12895 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
12896 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12899 let _ = ed.retarget_withdraw_container(container_id.clone());
12900 }
12901 self.re_open_sheet(S::WithdrawItems {
12902 container_id,
12903 lines,
12904 index: 0,
12905 });
12906 }
12907
12908 fn re_withdraw_items_activate(&mut self, index: usize) {
12909 use crate::worker_route_editor::{
12910 RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12911 };
12912 enum Outcome {
12913 Cycled,
12914 Confirmed(WorkerRouteStop),
12915 Empty,
12916 }
12917 let outcome = {
12918 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12919 return;
12920 };
12921 let S::WithdrawItems {
12922 container_id,
12923 lines,
12924 index: sheet_index,
12925 } = &mut ed.sheet
12926 else {
12927 return;
12928 };
12929 *sheet_index = index;
12930 if index < lines.len() {
12931 lines[index].cycle();
12932 Outcome::Cycled
12933 } else {
12934 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
12935 if items.is_empty() {
12936 Outcome::Empty
12937 } else {
12938 let stop = WorkerRouteStop::WithdrawFrom {
12939 container_id: container_id.clone(),
12940 items,
12941 };
12942 ed.confirm_stop(stop.clone());
12943 Outcome::Confirmed(stop)
12944 }
12945 }
12946 };
12947 match outcome {
12948 Outcome::Cycled => {}
12949 Outcome::Confirmed(stop) => {
12950 let what = self.state.worker_route_stop_summary(&stop);
12951 self.state.push_log(format!("Route: + {what}"));
12952 }
12953 Outcome::Empty => self.state.push_log(
12954 "Route: pick at least one item (Space/Enter toggles All/qty)".to_string(),
12955 ),
12956 }
12957 }
12958
12959 fn re_open_deposit_filter(&mut self, container_id: String) {
12960 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12961 let existing_filter = self
12963 .state
12964 .worker_route_editor
12965 .as_ref()
12966 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12967 .and_then(|stop| match stop {
12968 WorkerRouteStop::DepositAt { filter, .. } => {
12969 Some(filter.clone().unwrap_or_default())
12970 }
12971 _ => None,
12972 });
12973 let mut candidates = self.re_template_candidates();
12974 if let Some(ref chosen) = existing_filter {
12975 for t in chosen {
12976 if !candidates.iter().any(|c| c == t) {
12977 candidates.push(t.clone());
12978 }
12979 }
12980 candidates.sort();
12981 candidates.dedup();
12982 }
12983 let rows: Vec<(String, bool)> = match existing_filter {
12984 Some(chosen) => candidates
12985 .iter()
12986 .map(|t| (t.clone(), chosen.contains(t)))
12987 .collect(),
12988 None => candidates.into_iter().map(|t| (t, false)).collect(),
12989 };
12990 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12991 let _ = ed.retarget_deposit_container(container_id.clone());
12992 }
12993 self.re_open_sheet(S::DepositFilter {
12994 container_id,
12995 rows,
12996 index: 0,
12997 });
12998 }
12999
13000 fn re_deposit_filter_activate(&mut self, index: usize) {
13001 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13002 let mut confirmed: Option<WorkerRouteStop> = None;
13003 {
13004 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13005 return;
13006 };
13007 let S::DepositFilter {
13008 container_id,
13009 rows,
13010 index: sheet_index,
13011 } = &mut ed.sheet
13012 else {
13013 return;
13014 };
13015 *sheet_index = index;
13016 if index < rows.len() {
13017 rows[index].1 = !rows[index].1;
13018 } else {
13019 let chosen: Vec<String> = rows
13021 .iter()
13022 .filter(|(_, on)| *on)
13023 .map(|(t, _)| t.clone())
13024 .collect();
13025 let filter = if chosen.is_empty() {
13026 None
13027 } else {
13028 Some(chosen)
13029 };
13030 let stop = WorkerRouteStop::DepositAt {
13031 container_id: container_id.clone(),
13032 filter,
13033 };
13034 confirmed = Some(stop.clone());
13035 ed.confirm_stop(stop);
13036 }
13037 }
13038 if let Some(stop) = confirmed {
13039 let what = self.state.worker_route_stop_summary(&stop);
13040 self.state.push_log(format!("Route: + {what}"));
13041 }
13042 }
13043
13044 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
13045 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13046 let (pre_npc, pre_template, pre_all) = self
13048 .state
13049 .worker_route_editor
13050 .as_ref()
13051 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13052 .and_then(|stop| match stop {
13053 WorkerRouteStop::TradeWith {
13054 npc_id,
13055 template,
13056 sell_all,
13057 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
13058 _ => None,
13059 })
13060 .unwrap_or((None, None, true));
13061 let npc_id = npc_id.or(pre_npc);
13062 let mut templates = crate::worker_route_editor::sellable_route_item_template_candidates(
13063 &self.re_template_candidates(),
13064 &self.state.npcs,
13065 npc_id.as_deref(),
13066 );
13067 if let Some(template) = pre_template.as_ref() {
13070 if !templates.iter().any(|candidate| candidate == template) {
13071 templates.push(template.clone());
13072 templates.sort();
13073 }
13074 }
13075 if templates.is_empty() {
13076 let msg = crate::worker_route_editor::sell_merchant_empty_reason(
13077 npc_id.as_deref(),
13078 &self.state.npcs,
13079 &self.re_template_candidates(),
13080 );
13081 self.state.push_log(msg);
13082 return;
13083 }
13084 let mut picked = std::collections::BTreeSet::new();
13085 if let Some(t) = pre_template {
13086 picked.insert(t);
13087 }
13088 self.re_open_sheet(S::SellItem {
13089 npc_id,
13090 templates,
13091 index: if picked.is_empty() {
13092 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
13093 } else {
13094 2
13095 },
13096 sell_all: pre_all,
13097 picked,
13098 });
13099 }
13100
13101 fn re_sell_item_activate(&mut self, index: usize) {
13102 use crate::worker_route_editor::{
13103 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
13104 };
13105 let mut batch_log: Option<String> = None;
13106 {
13107 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13108 return;
13109 };
13110 let S::SellItem {
13111 npc_id,
13112 templates,
13113 index: sheet_index,
13114 sell_all,
13115 picked,
13116 } = &mut ed.sheet
13117 else {
13118 return;
13119 };
13120 *sheet_index = index;
13121 if index == ROUTE_PICKER_DONE_ROW {
13122 if picked.is_empty() {
13123 batch_log =
13124 Some("Route: pick at least one item (Space toggles, Done confirms)".into());
13125 } else {
13126 let picks: Vec<String> = picked.iter().cloned().collect();
13127 let npc = npc_id.clone();
13128 let all = *sell_all;
13129 let added = ed.confirm_trade_picks(npc, &picks, all);
13130 batch_log = Some(format!("Route: + {added} sell stop(s)"));
13131 }
13132 } else if index == SELL_ITEM_TOGGLE_ROW {
13133 *sell_all = !*sell_all;
13134 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13135 let sellable = crate::worker_route_editor::sellable_route_item_template_candidates(
13136 std::slice::from_ref(template),
13137 &self.state.npcs,
13138 npc_id.as_deref(),
13139 )
13140 .iter()
13141 .any(|candidate| candidate == template);
13142 if !sellable && !picked.contains(template) {
13143 return;
13144 }
13145 if picked.contains(template) {
13146 picked.remove(template);
13147 } else {
13148 picked.insert(template.clone());
13149 }
13150 }
13151 }
13152 if let Some(msg) = batch_log {
13153 self.state.push_log(msg);
13154 }
13155 }
13156
13157 fn re_open_market_list_item(&mut self) {
13158 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13159 let (pre_hall, pre_template, pre_all) = self
13160 .state
13161 .worker_route_editor
13162 .as_ref()
13163 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13164 .and_then(|stop| match stop {
13165 WorkerRouteStop::ListOnMarket {
13166 hall_id,
13167 template,
13168 list_all,
13169 } => Some((hall_id.clone(), Some(template.clone()), *list_all)),
13170 _ => None,
13171 })
13172 .unwrap_or((None, None, true));
13173 let mut templates = self.re_template_candidates();
13174 templates.sort_by_key(|t| {
13177 std::cmp::Reverse(self.state.item_base_value_copper_hint(t).unwrap_or(0))
13178 });
13179 if let Some(template) = pre_template.as_ref() {
13180 if !templates.iter().any(|c| c == template) {
13181 templates.push(template.clone());
13182 }
13183 }
13184 if templates.is_empty() {
13185 self.state
13186 .push_log("Route: no item templates available for market list".to_string());
13187 return;
13188 }
13189 let mut picked = std::collections::BTreeSet::new();
13190 if let Some(t) = pre_template {
13191 picked.insert(t);
13192 }
13193 self.re_open_sheet(S::MarketListItem {
13194 hall_id: pre_hall,
13195 templates,
13196 index: if picked.is_empty() {
13197 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
13198 } else {
13199 2
13200 },
13201 list_all: pre_all,
13202 picked,
13203 });
13204 }
13205
13206 fn re_market_list_item_activate(&mut self, index: usize) {
13207 use crate::worker_route_editor::{
13208 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
13209 };
13210 let mut batch_log: Option<String> = None;
13211 {
13212 let Some(ed) = self.state.worker_route_editor.as_mut() else {
13213 return;
13214 };
13215 let S::MarketListItem {
13216 hall_id,
13217 templates,
13218 index: sheet_index,
13219 list_all,
13220 picked,
13221 } = &mut ed.sheet
13222 else {
13223 return;
13224 };
13225 *sheet_index = index;
13226 if index == ROUTE_PICKER_DONE_ROW {
13227 if picked.is_empty() {
13228 batch_log =
13229 Some("Route: pick at least one item (Space toggles, Done confirms)".into());
13230 } else {
13231 let picks: Vec<String> = picked.iter().cloned().collect();
13232 let hall = hall_id.clone();
13233 let all = *list_all;
13234 let added = ed.confirm_market_list_picks(hall, &picks, all);
13235 batch_log = Some(format!("Route: + {added} market-list stop(s)"));
13236 }
13237 } else if index == SELL_ITEM_TOGGLE_ROW {
13238 *list_all = !*list_all;
13239 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13240 if picked.contains(template) {
13241 picked.remove(template);
13242 } else {
13243 picked.insert(template.clone());
13244 }
13245 }
13246 }
13247 if let Some(msg) = batch_log {
13248 self.state.push_log(msg);
13249 }
13250 }
13251
13252 pub fn re_edit_selected_stop(&mut self) {
13254 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13255 let Some(stop) = self
13256 .state
13257 .worker_route_editor
13258 .as_ref()
13259 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
13260 else {
13261 self.state
13262 .push_log("Route: no stop selected — press a to add one".to_string());
13263 return;
13264 };
13265 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13266 ed.begin_edit_selected();
13267 }
13268 match stop {
13269 WorkerRouteStop::Waypoint { .. } => {
13270 self.re_open_sheet(S::WaypointMenu { index: 0 });
13271 }
13272 WorkerRouteStop::HarvestNode { node_id } => {
13273 let nodes = self.state.route_editor_node_candidates();
13274 if nodes.is_empty() {
13275 self.re_cancel_edit();
13276 self.state.push_log(
13277 "Route: no harvestable nodes in this region to retarget".to_string(),
13278 );
13279 } else {
13280 let mut picked = std::collections::BTreeSet::new();
13281 picked.insert(node_id.clone());
13282 let index = nodes
13283 .iter()
13284 .position(|n| n.id == node_id)
13285 .map(|i| i + 1)
13286 .unwrap_or(1);
13287 self.re_open_harvest_picker(index, picked);
13288 }
13289 }
13290 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
13291 let containers = self.re_container_candidates();
13294 if containers.is_empty() {
13295 self.re_cancel_edit();
13296 self.state
13297 .push_log("Route: place a storage chest first".to_string());
13298 } else {
13299 let index = containers
13300 .iter()
13301 .position(|c| c.id == container_id)
13302 .unwrap_or(0);
13303 self.re_open_sheet(S::WithdrawContainers { index });
13304 }
13305 }
13306 WorkerRouteStop::DepositAt { container_id, .. } => {
13307 let containers = self.re_container_candidates();
13308 if containers.is_empty() {
13309 self.re_cancel_edit();
13310 self.state
13311 .push_log("Route: place a storage chest first".to_string());
13312 } else {
13313 let index = containers
13314 .iter()
13315 .position(|c| c.id == container_id)
13316 .unwrap_or(0);
13317 self.re_open_sheet(S::DepositContainers { index });
13318 }
13319 }
13320 WorkerRouteStop::TradeWith { npc_id, .. } => {
13321 let npcs = self.re_npc_candidates();
13322 let index = npc_id
13324 .as_ref()
13325 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
13326 .unwrap_or(0);
13327 self.re_open_sheet(S::SellNpcs { index });
13328 }
13329 WorkerRouteStop::ListOnMarket { .. } => {
13330 self.re_open_market_list_item();
13331 }
13332 WorkerRouteStop::CraftAt { blueprint, .. } => {
13333 let bps = self.re_blueprint_ids();
13334 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
13335 if bps.is_empty() {
13336 self.re_cancel_edit();
13337 self.state
13338 .push_log("Route: no known blueprints to retarget".to_string());
13339 } else {
13340 self.re_open_sheet(S::CraftBlueprint { index });
13341 }
13342 }
13343 WorkerRouteStop::CultivatePlot { .. } => {
13344 self.re_open_farm_plot_picker(
13345 crate::worker_route_editor::FarmPlotAction::Cultivate,
13346 );
13347 }
13348 WorkerRouteStop::PlantPlot { .. } => {
13349 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
13350 }
13351 WorkerRouteStop::HarvestPlot { .. } => {
13352 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
13353 }
13354 WorkerRouteStop::RestIfNeeded => {
13355 self.re_cancel_edit();
13356 self.state
13357 .push_log("Route: rest has no settings (change the bed with l)".to_string());
13358 }
13359 WorkerRouteStop::Wait { wait_ticks } => {
13360 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
13361 }
13362 }
13363 }
13364
13365 fn re_cancel_edit(&mut self) {
13366 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13367 ed.editing_index = None;
13368 }
13369 }
13370
13371 pub fn worker_route_editor_ui_click(
13374 &mut self,
13375 click: crate::worker_route_editor::RouteEditorClick,
13376 ) {
13377 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
13378 match click {
13379 RouteEditorClick::SelectStop(i) => {
13380 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13381 ed.sheet = S::Stops;
13382 ed.select_stop(i);
13383 }
13384 }
13385 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
13386 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
13387 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
13388 }
13389 }
13390
13391 pub fn re_sheet_row_activate(&mut self, row: usize) {
13393 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13394 let Some(sheet) = self
13395 .state
13396 .worker_route_editor
13397 .as_ref()
13398 .map(|ed| ed.sheet.clone())
13399 else {
13400 return;
13401 };
13402 match sheet {
13403 S::Stops => {
13404 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13405 ed.select_stop(row);
13406 }
13407 }
13408 S::AddMenu { .. } => match row {
13409 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
13410 1 => {
13411 if self.re_node_candidates().is_empty() {
13412 self.state
13413 .push_log("Route: no harvestable nodes in this region".to_string());
13414 } else {
13415 self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
13416 }
13417 }
13418 2 | 3 => {
13419 if self.re_container_candidates().is_empty() {
13420 self.state
13421 .push_log("Route: place a storage chest first".to_string());
13422 } else if row == 2 {
13423 self.re_open_sheet(S::WithdrawContainers { index: 0 });
13424 } else {
13425 self.re_open_sheet(S::DepositContainers { index: 0 });
13426 }
13427 }
13428 4 => {
13429 if self.re_template_candidates().is_empty() {
13430 self.state.push_log(
13431 "Route: no item templates available — learn a craft recipe or place a harvest node first"
13432 .to_string(),
13433 );
13434 } else {
13435 self.re_open_sheet(S::SellNpcs { index: 0 });
13436 }
13437 }
13438 5 => {
13439 if self.re_template_candidates().is_empty() {
13440 self.state.push_log(
13441 "Route: no item templates available — learn a craft recipe or place a harvest node first"
13442 .to_string(),
13443 );
13444 } else {
13445 self.re_open_market_list_item();
13446 }
13447 }
13448 6 => {
13449 if self.re_blueprint_ids().is_empty() {
13450 self.state.push_log(
13451 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
13452 .to_string(),
13453 );
13454 } else {
13455 self.re_open_sheet(S::CraftBlueprint { index: 0 });
13456 }
13457 }
13458 7 => self.re_confirm_stop(
13459 WorkerRouteStop::RestIfNeeded,
13460 "rest at lodging (if needed)".into(),
13461 ),
13462 8 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
13463 9 => self.re_open_farm_plot_picker(
13464 crate::worker_route_editor::FarmPlotAction::Cultivate,
13465 ),
13466 10 => {
13467 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant)
13468 }
13469 11 => self
13470 .re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
13471 _ => {}
13472 },
13473 S::WaypointMenu { .. } => match row {
13474 0 => {
13475 let (x, y, z) = self.state.player_position_with_z();
13476 let stop = WorkerRouteStop::Waypoint { x, y, z };
13477 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13478 }
13479 1 => {
13480 self.re_open_sheet(S::WaypointMapPick);
13481 self.state.push_log(
13482 "Route: click the map to place the waypoint (Esc to finish)".to_string(),
13483 );
13484 }
13485 _ => {}
13486 },
13487 S::HarvestPicker { .. } => {
13488 let mut log: Option<String> = None;
13489 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13490 let S::HarvestPicker {
13491 index: sheet_index,
13492 picked,
13493 nodes,
13494 } = &mut ed.sheet
13495 else {
13496 return;
13497 };
13498 *sheet_index = row;
13499 if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
13500 if picked.is_empty() {
13501 log = Some(
13502 "Route: pick at least one node (Space toggles, Done confirms)"
13503 .into(),
13504 );
13505 } else {
13506 let ids: Vec<String> = picked.iter().cloned().collect();
13507 let added = ed.confirm_harvest_picks(&ids);
13508 log = Some(format!("Route: + {added} harvest stop(s)"));
13509 }
13510 } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
13511 if picked.contains(&n.id) {
13512 picked.remove(&n.id);
13513 } else {
13514 picked.insert(n.id.clone());
13515 }
13516 }
13517 }
13518 if let Some(msg) = log {
13519 self.state.push_log(msg);
13520 }
13521 }
13522 S::WithdrawContainers { .. } => {
13523 let containers = self.re_container_candidates();
13524 if let Some(c) = containers.get(row) {
13525 let id = c.id.clone();
13526 self.re_open_withdraw_items(id);
13527 }
13528 }
13529 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
13530 S::DepositContainers { .. } => {
13531 let containers = self.re_container_candidates();
13532 if let Some(c) = containers.get(row) {
13533 let id = c.id.clone();
13534 self.re_open_deposit_filter(id);
13535 }
13536 }
13537 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
13538 S::SellNpcs { .. } => {
13539 let templates = self.re_template_candidates();
13540 let npcs = self.re_npc_candidates();
13541 if row == 0 {
13542 if !crate::worker_route_editor::any_trade_npc_buys_route_item(
13543 &self.state.npcs,
13544 &templates,
13545 ) {
13546 self.state.push_log(
13547 crate::worker_route_editor::sell_merchant_empty_reason(
13548 None,
13549 &self.state.npcs,
13550 &templates,
13551 ),
13552 );
13553 return;
13554 }
13555 self.re_open_sell_item(None);
13556 return;
13557 }
13558 let Some(n) = npcs.get(row - 1) else {
13559 return;
13560 };
13561 if !n.buys_route_item {
13562 self.state
13563 .push_log(crate::worker_route_editor::sell_merchant_empty_reason(
13564 Some(n.id.as_str()),
13565 &self.state.npcs,
13566 &templates,
13567 ));
13568 return;
13569 }
13570 self.re_open_sell_item(Some(n.id.clone()));
13571 }
13572 S::SellItem { .. } => self.re_sell_item_activate(row),
13573 S::MarketListItem { .. } => self.re_market_list_item_activate(row),
13574 S::CraftBlueprint { .. } => {
13575 let bps = self.re_blueprint_ids();
13576 if let Some(bp) = bps.get(row) {
13577 let stop = WorkerRouteStop::CraftAt {
13578 device: "hand".into(),
13579 blueprint: bp.clone(),
13580 qty: None,
13581 };
13582 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
13583 }
13584 }
13585 S::WaitEntry { ticks } => {
13586 let stop = WorkerRouteStop::Wait { wait_ticks: ticks };
13587 self.re_confirm_stop(stop, format!("wait {ticks}t"));
13588 }
13589 S::BedPicker { .. } => {
13590 let beds = self.re_bed_candidates();
13591 if let Some((id, name)) = beds.get(row) {
13592 let (id, name) = (id.clone(), name.clone());
13593 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13594 ed.lodging_container_id = Some(id.clone());
13595 ed.sheet = S::Stops;
13596 }
13597 self.state
13598 .push_log(format!("Route: rest bed set to {name}"));
13599 }
13600 }
13601 S::FarmPlotPicker { action, .. } => {
13602 let plots = self.re_farm_plot_candidates();
13603 let Some(plot) = plots.get(row).cloned() else {
13604 return;
13605 };
13606 match action {
13607 crate::worker_route_editor::FarmPlotAction::Cultivate => {
13608 let label = plot_route_label(&plot);
13609 self.re_confirm_stop(
13610 WorkerRouteStop::CultivatePlot {
13611 plot_id: plot.plot_id,
13612 },
13613 format!("cultivate {label}"),
13614 );
13615 }
13616 crate::worker_route_editor::FarmPlotAction::Harvest => {
13617 let label = plot_route_label(&plot);
13618 self.re_confirm_stop(
13619 WorkerRouteStop::HarvestPlot {
13620 plot_id: plot.plot_id,
13621 },
13622 format!("harvest {label}"),
13623 );
13624 }
13625 crate::worker_route_editor::FarmPlotAction::Plant => {
13626 let seeds = self.re_farm_seed_candidates();
13627 if seeds.is_empty() {
13628 self.state.push_log(
13629 "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
13630 );
13631 return;
13632 }
13633 self.re_open_sheet(S::FarmPlantSeed {
13634 plot_id: plot.plot_id,
13635 seeds,
13636 index: 0,
13637 });
13638 }
13639 }
13640 }
13641 S::FarmPlantSeed { plot_id, seeds, .. } => {
13642 if let Some(seed) = seeds.get(row).cloned() {
13643 self.re_confirm_stop(
13644 WorkerRouteStop::PlantPlot {
13645 plot_id,
13646 seed_template: seed.clone(),
13647 },
13648 format!("plant {seed}"),
13649 );
13650 }
13651 }
13652 S::WaypointMapPick => {}
13653 }
13654 }
13655
13656 fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
13657 use crate::worker_route_editor::RouteEditorSheet as S;
13658 if self.re_farm_plot_candidates().is_empty() {
13659 self.state
13660 .push_log("Route: no farmable plots visible — claim land or get farm access first");
13661 return;
13662 }
13663 self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
13664 }
13665
13666 fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
13667 self.state
13668 .property_plots
13669 .iter()
13670 .filter(|p| p.is_mine || p.may_farm)
13671 .cloned()
13672 .collect()
13673 }
13674
13675 fn re_farm_seed_candidates(&self) -> Vec<String> {
13679 let mut set = std::collections::BTreeSet::new();
13680 let looks_like_seed =
13681 |id: &str, catalog: &std::collections::HashMap<String, ItemCatalogEntryView>| {
13682 catalog.get(id).is_some_and(|e| e.is_farm_seed()) || id.ends_with("_seed")
13683 };
13684 for (id, _, _) in self.state.farm_seed_entries() {
13685 set.insert(id);
13686 }
13687 for c in &self.state.placed_containers {
13688 let mine = match (self.state.character_id, c.owner_character_id) {
13689 (Some(a), Some(b)) => a == b,
13690 _ => false,
13691 };
13692 if !mine {
13693 continue;
13694 }
13695 for s in &c.contents {
13696 if s.quantity > 0
13697 && (s.props.contains_key("seed_for")
13698 || looks_like_seed(&s.template_id, &self.state.item_catalog))
13699 {
13700 set.insert(s.template_id.clone());
13701 }
13702 }
13703 }
13704 if let Some(ed) = self.state.worker_route_editor.as_ref() {
13705 for stop in &ed.stops {
13706 if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } =
13707 stop
13708 {
13709 for it in items {
13710 if looks_like_seed(&it.template, &self.state.item_catalog) {
13711 set.insert(it.template.clone());
13712 }
13713 }
13714 }
13715 if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
13716 seed_template,
13717 ..
13718 } = stop
13719 {
13720 if !seed_template.is_empty() {
13721 set.insert(seed_template.clone());
13722 }
13723 }
13724 }
13725 }
13726 for (id, entry) in &self.state.item_catalog {
13727 if entry.is_farm_seed() {
13728 set.insert(id.clone());
13729 }
13730 }
13731 set.into_iter().collect()
13732 }
13733
13734 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
13741 use crate::worker_route_editor as wre;
13742 use wre::RouteEditorSheet as S;
13743 if self.state.worker_route_editor.is_none() {
13744 return;
13745 }
13746 let sheet = self
13747 .state
13748 .worker_route_editor
13749 .as_ref()
13750 .map(|ed| ed.sheet.clone())
13751 .unwrap_or(S::Stops);
13752 match sheet {
13753 S::WaypointMapPick => {
13754 let (_, _, z) = self.state.player_position_with_z();
13755 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
13756 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13757 let editing = self
13759 .state
13760 .worker_route_editor
13761 .as_ref()
13762 .is_some_and(|ed| ed.editing_index.is_some());
13763 if !editing {
13764 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13765 ed.sheet = S::WaypointMapPick;
13766 }
13767 }
13768 }
13769 S::HarvestPicker { .. } => {
13770 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13771 let mut log: Option<String> = None;
13772 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13773 let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
13774 return;
13775 };
13776 let selected = if picked.contains(&node.id) {
13777 picked.remove(&node.id);
13778 false
13779 } else {
13780 picked.insert(node.id.clone());
13781 true
13782 };
13783 log = Some(format!(
13784 "Route: {} {}",
13785 if selected { "selected" } else { "deselected" },
13786 resource_node_route_label(node)
13787 ));
13788 }
13789 if let Some(msg) = log {
13790 self.state.push_log(msg);
13791 }
13792 }
13793 }
13794 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
13795 let inside = self.state.effective_inside_building();
13797 if let Some(cid) = wre::pick_storage_container_at(
13798 &self.state.placed_containers,
13799 self.state.character_id,
13800 x,
13801 y,
13802 inside.as_deref(),
13803 ) {
13804 self.re_open_withdraw_items(cid);
13805 }
13806 }
13807 S::DepositContainers { .. } | S::DepositFilter { .. } => {
13808 let inside = self.state.effective_inside_building();
13809 if let Some(cid) = wre::pick_storage_container_at(
13810 &self.state.placed_containers,
13811 self.state.character_id,
13812 x,
13813 y,
13814 inside.as_deref(),
13815 ) {
13816 self.re_open_deposit_filter(cid);
13817 }
13818 }
13819 S::SellNpcs { .. } => {
13820 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13821 self.re_open_sell_item(Some(npc_id));
13822 }
13823 }
13824 S::SellItem { .. } => {
13825 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13826 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13827 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
13828 *slot = Some(npc_id.clone());
13829 }
13830 }
13831 self.state
13832 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13833 }
13834 }
13835 _ => self.worker_route_editor_quick_add_click(x, y),
13837 }
13838 }
13839
13840 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
13844 use crate::worker_route_editor as wre;
13845 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
13846 let dx = ax - bx;
13847 let dy = ay - by;
13848 (dx * dx + dy * dy).sqrt()
13849 };
13850
13851 let selected_stop_kind = self
13854 .state
13855 .worker_route_editor
13856 .as_ref()
13857 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
13858 .map(|s| match s {
13859 wre::WorkerRouteStop::TradeWith { .. } => 1,
13860 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
13861 _ => 0,
13862 })
13863 .unwrap_or(0);
13864 if selected_stop_kind == 1 {
13865 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13866 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13867 ed.set_selected_trade_npc(npc_id.clone());
13868 }
13869 self.state
13870 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13871 return;
13872 }
13873 }
13874 if selected_stop_kind == 2 {
13875 let inside = self.state.effective_inside_building();
13876 if let Some(cid) = wre::pick_storage_container_at(
13877 &self.state.placed_containers,
13878 self.state.character_id,
13879 x,
13880 y,
13881 inside.as_deref(),
13882 ) {
13883 let name = self
13884 .state
13885 .placed_containers
13886 .iter()
13887 .find(|c| c.id == cid)
13888 .map(|c| c.display_name.clone())
13889 .unwrap_or_else(|| "container".into());
13890 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13891 ed.set_selected_withdraw_container(cid.clone());
13892 }
13893 self.state
13894 .push_log(format!("Route: withdraw source → {name}"));
13895 return;
13896 }
13897 }
13898
13899 enum Target {
13902 Bed(String),
13903 Container(String),
13904 Npc(String, String),
13905 Node(String, String),
13906 }
13907 let mut best: Option<(f32, u8, Target)> = None;
13908 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
13909 let better = match best {
13910 None => true,
13911 Some((bd, brank, _)) => {
13912 d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank)
13913 }
13914 };
13915 if better {
13916 *best = Some((d, rank, t));
13917 }
13918 };
13919 let inside = self.state.effective_inside_building();
13920 if let Some(bed_id) = wre::pick_lodging_container_at(
13921 &self.state.placed_containers,
13922 self.state.character_id,
13923 x,
13924 y,
13925 inside.as_deref(),
13926 ) {
13927 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
13928 let already_bed =
13931 self.state.worker_route_editor.as_ref().is_some_and(|ed| {
13932 ed.lodging_container_id.as_deref() == Some(bed_id.as_str())
13933 });
13934 if already_bed {
13935 consider(
13936 dist(x, y, c.x, c.y),
13937 1,
13938 Target::Container(bed_id),
13939 &mut best,
13940 );
13941 } else {
13942 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
13943 }
13944 }
13945 }
13946 if let Some(cid) = wre::pick_storage_container_at(
13947 &self.state.placed_containers,
13948 self.state.character_id,
13949 x,
13950 y,
13951 inside.as_deref(),
13952 ) {
13953 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
13954 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
13955 }
13956 }
13957 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13958 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
13959 consider(
13960 dist(x, y, n.x, n.y),
13961 2,
13962 Target::Npc(npc_id, label),
13963 &mut best,
13964 );
13965 }
13966 }
13967 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13968 let d = dist(x, y, node.x, node.y);
13969 let label = resource_node_route_label(node);
13970 consider(d, 3, Target::Node(node.id.clone(), label), &mut best);
13971 }
13972
13973 match best.map(|(_, _, t)| t) {
13974 Some(Target::Bed(bed_id)) => {
13975 let name = self
13976 .state
13977 .placed_containers
13978 .iter()
13979 .find(|c| c.id == bed_id)
13980 .map(|c| c.display_name.clone())
13981 .unwrap_or_else(|| "camp bed".into());
13982 if let Some(ed) = self.state.worker_route_editor.as_mut() {
13983 ed.lodging_container_id = Some(bed_id.clone());
13984 }
13985 self.state
13986 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
13987 }
13988 Some(Target::Container(cid)) => {
13989 let name = self
13990 .state
13991 .placed_containers
13992 .iter()
13993 .find(|c| c.id == cid)
13994 .map(|c| c.display_name.clone())
13995 .unwrap_or_else(|| "container".into());
13996 let added = self
13997 .state
13998 .worker_route_editor
13999 .as_mut()
14000 .is_some_and(|ed| ed.append_deposit_at(&cid));
14001 if added {
14002 self.state
14003 .push_log(format!("Route: + deposit at {name} ({cid})"));
14004 } else {
14005 self.state.push_log(format!(
14006 "Route: {name} already in route — selected it (d to remove)"
14007 ));
14008 }
14009 }
14010 Some(Target::Npc(npc_id, label)) => {
14011 let template = self.re_template_candidates().into_iter().next();
14014 let Some(template) = template else {
14015 self.state.push_log(
14016 "Route: no items in your storage to sell — stock a chest first".to_string(),
14017 );
14018 return;
14019 };
14020 let added = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
14021 ed.append_trade_with(template.clone(), Some(npc_id.clone()), true)
14022 });
14023 if added {
14024 self.state
14025 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
14026 } else {
14027 self.state.push_log(format!(
14028 "Route: {label} already sells {template} — selected it (d to remove)"
14029 ));
14030 }
14031 }
14032 Some(Target::Node(id, label)) => {
14033 let added = self
14034 .state
14035 .worker_route_editor
14036 .as_mut()
14037 .is_some_and(|ed| ed.append_harvest_node(&id));
14038 if added {
14039 self.state
14040 .push_log(format!("Route: + harvest node {label}"));
14041 } else {
14042 self.state.push_log(format!(
14043 "Route: {label} already in route — selected it (d to remove)"
14044 ));
14045 }
14046 }
14047 None => {}
14048 }
14049 }
14050
14051 pub fn worker_route_editor_select(&mut self, delta: i32) {
14052 let Some(ed) = self.state.worker_route_editor.as_mut() else {
14053 return;
14054 };
14055 if ed.stops.is_empty() {
14056 return;
14057 }
14058 let n = ed.stops.len() as i32;
14059 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
14060 ed.selected_stop_index = next;
14061 }
14062
14063 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
14064 let Some(ed) = self.state.worker_route_editor.as_mut() else {
14065 return;
14066 };
14067 if delta < 0 {
14068 ed.move_selected_up();
14069 } else if delta > 0 {
14070 ed.move_selected_down();
14071 }
14072 }
14073
14074 pub fn worker_route_editor_delete_selected(&mut self) {
14075 let removed = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
14076 let before = ed.stop_count();
14077 ed.remove_selected_stop();
14078 ed.stop_count() < before
14079 });
14080 if removed {
14081 self.state.push_log("Route: removed selected stop");
14082 }
14083 }
14084
14085 pub fn worker_route_editor_clear_stops(&mut self) {
14088 let Some(ed) = self.state.worker_route_editor.as_mut() else {
14089 return;
14090 };
14091 if ed.stops.is_empty() {
14092 self.state
14093 .push_log("Route: already empty — s saves an idle worker".to_string());
14094 return;
14095 }
14096 ed.stops.clear();
14097 ed.selected_stop_index = 0;
14098 self.state.push_log(
14099 "Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string(),
14100 );
14101 }
14102
14103 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
14104 if self.state.pending_worker_job_ack.is_some() {
14105 anyhow::bail!("route save still pending — wait for server ack");
14106 }
14107 let Some(ed) = self.state.worker_route_editor.clone() else {
14108 anyhow::bail!("route editor not open");
14109 };
14110 let (job_yaml, idle) = if ed.stops.is_empty() {
14113 (ed.build_idle_job_yaml(), true)
14114 } else {
14115 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
14116 };
14117 let worker_id = ed.worker_instance_id.clone();
14118 let route_view = if idle { None } else { Some(ed.to_route_view()) };
14119 let mode = if idle {
14120 flatland_protocol::WorkerModeView::Idle
14121 } else {
14122 flatland_protocol::WorkerModeView::JobLoop
14123 };
14124 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
14125 .state
14126 .hired_workers
14127 .iter()
14128 .find(|w| w.instance_id == worker_id)
14129 .map(|w| {
14130 (
14131 w.route.clone(),
14132 w.mode,
14133 w.step_label.clone(),
14134 w.last_error.clone(),
14135 )
14136 })
14137 .unwrap_or((
14138 None,
14139 flatland_protocol::WorkerModeView::Idle,
14140 String::new(),
14141 None,
14142 ));
14143 self.seq += 1;
14144 let seq = self.seq;
14145 self.session
14146 .submit_intent(Intent::SetWorkerJob {
14147 entity_id: self.state.entity_id,
14148 worker_instance_id: worker_id.clone(),
14149 job_yaml,
14150 seq,
14151 })
14152 .await?;
14153 self.state.intents_sent += 1;
14154 if let Some(w) = self
14155 .state
14156 .hired_workers
14157 .iter_mut()
14158 .find(|w| w.instance_id == worker_id)
14159 {
14160 w.route = route_view;
14161 w.mode = mode;
14162 w.last_error = None;
14163 if idle {
14164 w.step_label.clear();
14165 w.route_stop_index = None;
14166 }
14167 }
14168 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
14169 seq,
14170 worker_instance_id: worker_id,
14171 worker_label: ed.worker_label.clone(),
14172 idle,
14173 stop_count: ed.stops.len(),
14174 prev_route,
14175 prev_mode,
14176 prev_step_label,
14177 prev_last_error,
14178 });
14179 self.state.push_log(format!(
14180 "Route: saving for {}… (waiting for server)",
14181 ed.worker_label
14182 ));
14183 Ok(())
14185 }
14186 pub fn quest_menu_move(&mut self, delta: i32) {
14187 let n = self.state.active_quest_entries().len();
14188 if n == 0 {
14189 return;
14190 }
14191 let idx = self.state.quest_menu_index as i32;
14192 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
14193 }
14194
14195 pub fn quest_menu_page(&mut self, pages: i32) {
14196 let n = self.state.active_quest_entries().len();
14197 self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
14198 }
14199
14200 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
14201 let Some(offer) = self.state.selected_quest_offer().cloned() else {
14202 anyhow::bail!("no quest offer");
14203 };
14204 self.seq += 1;
14205 let seq = self.seq;
14206 self.session
14207 .submit_intent(Intent::AcceptQuest {
14208 entity_id: self.state.entity_id,
14209 quest_id: offer.quest_id,
14210 seq,
14211 })
14212 .await?;
14213 self.state.intents_sent += 1;
14214 Ok(())
14215 }
14216
14217 pub fn quest_offer_move(&mut self, delta: i32) {
14218 self.state.move_quest_offer_selection(delta);
14219 }
14220
14221 pub fn quest_offer_decline(&mut self) {
14222 self.state.clear_quest_offers();
14223 if !self.state.show_npc_chat
14224 && !self.state.show_shop_menu
14225 && self.state.npc_verb_target.is_some()
14226 {
14227 self.state.show_npc_verb_menu = true;
14228 }
14229 }
14230
14231 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
14232 if !self.state.show_quest_menu {
14233 return Ok(());
14234 }
14235 let active: Vec<_> = self
14236 .state
14237 .active_quest_entries()
14238 .into_iter()
14239 .cloned()
14240 .collect();
14241 let Some(entry) = active.get(self.state.quest_menu_index) else {
14242 return Ok(());
14243 };
14244 if self.state.quest_withdraw_confirm {
14245 if !entry.can_withdraw {
14246 anyhow::bail!("quest cannot be withdrawn");
14247 }
14248 self.seq += 1;
14249 let seq = self.seq;
14250 self.session
14251 .submit_intent(Intent::WithdrawQuest {
14252 entity_id: self.state.entity_id,
14253 quest_id: entry.quest_id.clone(),
14254 seq,
14255 })
14256 .await?;
14257 self.state.intents_sent += 1;
14258 self.state.quest_withdraw_confirm = false;
14259 return Ok(());
14260 }
14261 self.seq += 1;
14262 let seq = self.seq;
14263 self.session
14264 .submit_intent(Intent::TrackQuest {
14265 entity_id: self.state.entity_id,
14266 quest_id: entry.quest_id.clone(),
14267 seq,
14268 })
14269 .await?;
14270 self.state.intents_sent += 1;
14271 Ok(())
14272 }
14273
14274 pub fn quest_request_withdraw(&mut self) {
14275 if self.state.show_quest_menu {
14276 self.state.quest_withdraw_confirm = true;
14277 }
14278 }
14279
14280 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
14281 if !self.state.is_alive() {
14282 anyhow::bail!("you are dead");
14283 }
14284 let Some(catalog) = self.state.shop_catalog.clone() else {
14285 anyhow::bail!("no shop open");
14286 };
14287 self.seq += 1;
14288 let seq = self.seq;
14289 match self.state.shop_tab {
14290 ShopTab::Buy => {
14291 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
14292 anyhow::bail!("nothing selected");
14293 };
14294 if offer.already_owned {
14295 anyhow::bail!("already owned");
14296 }
14297 self.session
14298 .submit_intent(Intent::ShopBuy {
14299 entity_id: self.state.entity_id,
14300 npc_id: catalog.npc_id.clone(),
14301 offer_id: offer.offer_id.clone(),
14302 quantity: self.state.shop_quantity,
14303 seq,
14304 })
14305 .await?;
14306 }
14307 ShopTab::Sell => {
14308 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
14309 anyhow::bail!("nothing to sell");
14310 };
14311 if line.quantity == 0 {
14312 anyhow::bail!("you have no {}", line.label);
14313 }
14314 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
14315 self.session
14316 .submit_intent(Intent::ShopSell {
14317 entity_id: self.state.entity_id,
14318 npc_id: catalog.npc_id.clone(),
14319 template_id: line.template_id.clone(),
14320 quantity,
14321 seq,
14322 })
14323 .await?;
14324 }
14325 }
14326 self.state.intents_sent += 1;
14327 Ok(())
14328 }
14329
14330 pub fn craft_menu_move(&mut self, delta: i32) {
14331 let n = self.state.craft_filtered_indices().len();
14332 if n == 0 {
14333 return;
14334 }
14335 let idx = self.state.craft_menu_index as i32;
14336 let next = (idx + delta).rem_euclid(n as i32);
14337 self.state.craft_menu_index = next as usize;
14338 self.state.clamp_craft_batch_quantity();
14339 }
14340
14341 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
14342 self.state.craft_batch_adjust_quantity(delta);
14343 }
14344
14345 pub fn craft_batch_set_max(&mut self) {
14346 self.state.craft_batch_set_max();
14347 }
14348
14349 pub fn craft_batch_set_min(&mut self) {
14350 self.state.craft_batch_set_min();
14351 }
14352
14353 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
14354 let Some(blueprint) = self.state.craft_selected_blueprint().cloned() else {
14355 anyhow::bail!("no blueprints in this tab");
14356 };
14357 if !self.state.can_craft_blueprint(&blueprint) {
14358 let hint = self
14359 .state
14360 .craft_missing_hint(&blueprint)
14361 .unwrap_or_else(|| "missing materials".into());
14362 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
14363 }
14364 let count = self.state.craft_batch_quantity;
14365 let max = self.state.max_craft_batches(&blueprint);
14366 if max == 0 {
14367 anyhow::bail!("cannot craft {}", blueprint.label);
14368 }
14369 let batches = count.min(max);
14370 self.craft(&blueprint.id, Some(batches)).await?;
14371 Ok(())
14373 }
14374
14375 pub async fn move_by(
14376 &mut self,
14377 forward: f32,
14378 strafe: f32,
14379 vertical: f32,
14380 sprint: bool,
14381 sneak: bool,
14382 ) -> anyhow::Result<()> {
14383 if !self.state.is_alive() {
14384 anyhow::bail!("you are dead");
14385 }
14386 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
14387 self.last_move_forward = forward;
14388 self.last_move_strafe = strafe;
14389 }
14390 self.seq += 1;
14391 self.session
14392 .submit_intent(Intent::Move {
14393 entity_id: self.state.entity_id,
14394 forward,
14395 strafe,
14396 vertical,
14397 sprint: sprint && !sneak,
14398 sneak,
14399 seq: self.seq,
14400 })
14401 .await?;
14402 self.state.intents_sent += 1;
14403 Ok(())
14404 }
14405
14406 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
14407 if !self.state.connected {
14408 crate::harvest_trace!("harvest_nearest rejected: not connected");
14409 anyhow::bail!("not connected");
14410 }
14411 if !self.state.is_alive() {
14412 crate::harvest_trace!("harvest_nearest rejected: player dead");
14413 anyhow::bail!("you are dead");
14414 }
14415 if self.state.harvest_in_progress {
14416 if self.state.harvest_state_stale() {
14417 self.state.clear_harvest_state();
14418 } else {
14419 anyhow::bail!("already harvesting");
14420 }
14421 }
14422 let (px, py) = self
14423 .state
14424 .player
14425 .as_ref()
14426 .map(|p| (p.transform.position.x, p.transform.position.y))
14427 .unwrap_or((0.0, 0.0));
14428
14429 let available = self
14430 .state
14431 .resource_nodes
14432 .iter()
14433 .filter(|n| !n.harvest_off)
14434 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14435 .count();
14436 let node_id = self
14437 .state
14438 .resource_nodes
14439 .iter()
14440 .filter(|n| !n.harvest_off)
14441 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14442 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
14443 .min_by(|a, b| {
14444 let da = distance(px, py, a.x, a.y);
14445 let db = distance(px, py, b.x, b.y);
14446 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
14447 })
14448 .map(|n| n.id.clone());
14449
14450 let Some(node_id) = node_id else {
14451 let has_loot = self
14452 .state
14453 .ground_drops
14454 .iter()
14455 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14456 if has_loot {
14457 return self.pickup_nearest().await;
14458 }
14459 anyhow::bail!(
14460 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
14461 );
14462 };
14463
14464 self.seq += 1;
14465 let seq = self.seq;
14466 crate::harvest_trace!(
14467 entity_id = self.state.entity_id,
14468 node_id = %node_id,
14469 seq,
14470 px,
14471 py,
14472 available_nodes = available,
14473 "submitting harvest intent"
14474 );
14475 self.session
14476 .submit_intent(Intent::Harvest {
14477 entity_id: self.state.entity_id,
14478 node_id,
14479 seq,
14480 })
14481 .await?;
14482 self.state.intents_sent += 1;
14483 self.state.harvest_in_progress = true;
14484 self.state.harvest_started_at = Some(Instant::now());
14485 self.state.push_log("Harvesting…");
14486 crate::harvest_trace!(
14487 entity_id = self.state.entity_id,
14488 seq,
14489 "harvest intent queued to session"
14490 );
14491 Ok(())
14492 }
14493
14494 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
14495 if !self.state.is_alive() {
14496 anyhow::bail!("you are dead");
14497 }
14498 let blueprint_id = self
14499 .state
14500 .blueprints
14501 .iter()
14502 .find(|bp| self.state.can_craft_blueprint(bp))
14503 .map(|bp| bp.id.clone())
14504 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
14505 self.craft(&blueprint_id, None).await
14506 }
14507
14508 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
14509 if !self.state.is_alive() {
14510 anyhow::bail!("you are dead");
14511 }
14512 self.seq += 1;
14513 self.session
14514 .submit_intent(Intent::Craft {
14515 entity_id: self.state.entity_id,
14516 blueprint_id: blueprint_id.to_string(),
14517 count,
14518 seq: self.seq,
14519 })
14520 .await?;
14521 self.state.intents_sent += 1;
14522 let (label, batches) = self
14523 .state
14524 .blueprints
14525 .iter()
14526 .find(|b| b.id == blueprint_id)
14527 .map(|b| {
14528 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
14529 (b.label.as_str(), n)
14530 })
14531 .unwrap_or((blueprint_id, count.unwrap_or(1)));
14532 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
14533 self.state.craft_channel_blueprint_id = Some(blueprint_id.to_string());
14534 Ok(())
14535 }
14536
14537 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
14538 if !self.state.is_alive() {
14539 anyhow::bail!("you are dead");
14540 }
14541 let target_id = match self.state.nearest_interact_target() {
14542 Some(id) => id,
14543 None => {
14544 anyhow::bail!("nothing to interact with nearby");
14545 }
14546 };
14547 if self.state.npcs.iter().any(|n| n.id == target_id) {
14548 self.state.show_npc_verb_menu = true;
14549 self.state.npc_verb_target = Some(target_id);
14550 self.state.npc_verb_index = 0;
14551 self.state.npc_verb_notice = None;
14552 return Ok(());
14553 }
14554 if self
14555 .state
14556 .hired_workers
14557 .iter()
14558 .any(|w| w.instance_id == target_id)
14559 {
14560 return self.open_workers_menu_for(&target_id).await;
14561 }
14562 if let Ok(peer_id) = target_id.parse::<EntityId>() {
14563 if self
14564 .state
14565 .hired_workers
14566 .iter()
14567 .any(|w| w.entity_id == peer_id)
14568 {
14569 if let Some(w) = self
14570 .state
14571 .hired_workers
14572 .iter()
14573 .find(|w| w.entity_id == peer_id)
14574 {
14575 let id = w.instance_id.clone();
14576 return self.open_workers_menu_for(&id).await;
14577 }
14578 }
14579 if let Some(entity) = self
14580 .state
14581 .entities
14582 .iter()
14583 .find(|e| e.id == peer_id && e.id != self.state.entity_id)
14584 {
14585 self.state.player_verbs.open_for(peer_id, &entity.label);
14586 return Ok(());
14587 }
14588 }
14589 self.seq += 1;
14590 self.session
14591 .submit_intent(Intent::Interact {
14592 entity_id: self.state.entity_id,
14593 target_id: target_id.clone(),
14594 seq: self.seq,
14595 })
14596 .await?;
14597 self.state.intents_sent += 1;
14598 Ok(())
14599 }
14600
14601 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
14606 if !self.state.is_alive() {
14607 anyhow::bail!("you are dead");
14608 }
14609 let (px, py) = self.state.player_position();
14610 let has_loot = self
14611 .state
14612 .ground_drops
14613 .iter()
14614 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14615 if has_loot {
14616 return self.pickup_nearest().await;
14617 }
14618
14619 if let Some(primary) = self.state.probe_use_world().primary {
14621 match primary.kind.cascade_stage() {
14622 0 => return self.interact_nearest().await,
14623 2 => return self.pickup_nearest_container().await,
14624 3 => return self.harvest_nearest().await,
14625 _ => {}
14626 }
14627 }
14628
14629 if let Some(plot) = self.state.my_plot_under_player().cloned() {
14630 const SELL_WINDOW: Duration = Duration::from_millis(1200);
14632 let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
14633 && self
14634 .state
14635 .sell_plot_armed_at
14636 .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
14637 if sell_armed {
14638 return self.confirm_sell_plot_to_crown(plot.plot_id).await;
14639 }
14640 self.state.sell_plot_confirm = None;
14641 self.state.sell_plot_armed_at = None;
14642
14643 let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
14646 self.state.npcs.iter().any(|n| n.id == id)
14647 || self.state.hired_workers.iter().any(|w| w.instance_id == id)
14648 || self.state.doors.iter().any(|d| d.id == id)
14649 || self.state.interactables.iter().any(|i| {
14650 i.id == id
14651 && matches!(i.kind.as_str(), "quest_board" | "well" | "exit" | "enter")
14652 })
14653 || id.parse::<EntityId>().is_ok_and(|eid| {
14654 self.state
14655 .entities
14656 .iter()
14657 .any(|e| e.id == eid && e.id != self.state.entity_id)
14658 })
14659 });
14660 if !blocking_interact {
14661 match self.harvest_nearest().await {
14663 Ok(()) => return Ok(()),
14664 Err(err) => {
14665 let msg = err.to_string();
14666 if !(msg.contains("no harvestable")
14667 || msg.contains("press p")
14668 || msg.contains("press f")
14669 || msg.contains("nothing"))
14670 {
14671 return Err(err);
14672 }
14673 }
14674 }
14675 return Ok(());
14676 }
14677 }
14678 if self.state.nearest_interact_target().is_some() {
14679 return self.interact_nearest().await;
14680 }
14681 if let Some((label, dist)) = self.state.nearest_quest_board() {
14684 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
14685 anyhow::bail!(
14686 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
14687 );
14688 }
14689 }
14690
14691 match self.harvest_nearest().await {
14692 Ok(()) => Ok(()),
14693 Err(err) => {
14694 let msg = err.to_string();
14695 if msg.contains("no harvestable")
14696 || msg.contains("press p")
14697 || msg.contains("press f")
14698 {
14699 anyhow::bail!(
14700 "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
14701 );
14702 }
14703 Err(err)
14704 }
14705 }
14706 }
14707
14708 pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
14710 if !self.state.is_alive() {
14711 anyhow::bail!("you are dead");
14712 }
14713 if self.state.claim_mode.is_some() {
14714 anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
14715 }
14716 let zone = self
14717 .state
14718 .free_property_zone_under_player()
14719 .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
14720 let zone_id = zone.id.clone();
14721 let label = zone
14722 .label
14723 .as_deref()
14724 .filter(|s| !s.trim().is_empty())
14725 .unwrap_or(zone.id.as_str())
14726 .to_string();
14727 self.enter_claim_mode(&zone_id);
14728 self.state.push_log(format!(
14729 "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
14730 ));
14731 Ok(())
14732 }
14733
14734 pub fn enter_claim_mode(&mut self, zone_id: &str) {
14736 let Some(zone) = self
14737 .state
14738 .property_zones
14739 .iter()
14740 .find(|z| z.id == zone_id)
14741 .cloned()
14742 else {
14743 self.state.push_log("unknown property zone");
14744 return;
14745 };
14746 self.state.sell_plot_confirm = None;
14747 self.state.sell_plot_armed_at = None;
14748 let min_area = self
14749 .state
14750 .property_plot_settings
14751 .as_ref()
14752 .map(|s| s.min_plot_area_m2)
14753 .unwrap_or(4.0)
14754 .max(1.0);
14755 let min_side = min_area.sqrt().ceil().max(1.0) as u32;
14756 let side = 4u32.max(min_side);
14757 let (px, py) = self.state.player_position();
14758 let anchor_x = px.floor();
14759 let anchor_y = py.floor();
14760 self.state.claim_mode = Some(ClaimModeState {
14761 zone_id: zone.id.clone(),
14762 width_m: side,
14763 height_m: side,
14764 anchor_x,
14765 anchor_y,
14766 });
14767 let label = zone
14768 .label
14769 .as_deref()
14770 .filter(|s| !s.trim().is_empty())
14771 .unwrap_or(zone.id.as_str());
14772 self.state.push_log(format!(
14773 "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
14774 ));
14775 }
14776
14777 pub fn cancel_claim_mode(&mut self) {
14778 if self.state.claim_mode.take().is_some() {
14779 self.state.push_log("Claim cancelled");
14780 }
14781 }
14782
14783 pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
14785 if !self.state.is_alive() {
14786 anyhow::bail!("you are dead");
14787 }
14788 if self.state.relocate_mode.is_some() {
14789 anyhow::bail!("already relocating — Enter confirm, Esc cancel");
14790 }
14791 if self.state.claim_mode.is_some() {
14792 anyhow::bail!("finish or cancel claim mode first");
14793 }
14794 let chest = self
14795 .state
14796 .placed_containers
14797 .iter()
14798 .find(|c| c.id == container_id)
14799 .cloned()
14800 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
14801 let (px, py) = self.state.player_position();
14802 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
14803 anyhow::bail!("too far from {}", chest.display_name);
14804 }
14805 if chest.locked && !chest.accessible {
14806 anyhow::bail!(
14807 "need the matching key for {} before moving it",
14808 chest.display_name
14809 );
14810 }
14811 let label = if chest.display_name.trim().is_empty() {
14812 chest.template_id.clone()
14813 } else {
14814 chest.display_name.clone()
14815 };
14816 self.state.relocate_mode = Some(RelocateModeState {
14817 container_id: chest.id.clone(),
14818 label: label.clone(),
14819 cursor_x: chest.x.floor() + 0.5,
14820 cursor_y: chest.y.floor() + 0.5,
14821 });
14822 self.state.push_log(format!(
14823 "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
14824 ));
14825 Ok(())
14826 }
14827
14828 pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
14830 let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
14831 anyhow::bail!("no chest nearby to relocate");
14832 };
14833 if chest.locked && !chest.accessible {
14834 anyhow::bail!(
14835 "need the matching key for {} before moving it",
14836 chest.display_name
14837 );
14838 }
14839 self.begin_relocate_container(&chest.id)
14842 }
14843
14844 pub fn cancel_relocate_mode(&mut self) {
14845 if self.state.relocate_mode.take().is_some() {
14846 self.state.push_log("Relocate cancelled");
14847 }
14848 }
14849
14850 pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
14851 let Some(mode) = self.state.relocate_mode.as_mut() else {
14852 return;
14853 };
14854 let max_x = self.state.world_width_m.max(1.0);
14855 let max_y = self.state.world_height_m.max(1.0);
14856 let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
14857 let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
14858 mode.cursor_x = nx.floor() + 0.5;
14859 mode.cursor_y = ny.floor() + 0.5;
14860 }
14861
14862 pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
14863 let Some(mode) = self.state.relocate_mode.as_mut() else {
14864 return;
14865 };
14866 let max_x = self.state.world_width_m.max(1.0);
14867 let max_y = self.state.world_height_m.max(1.0);
14868 mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
14869 mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
14870 }
14871
14872 pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
14873 if !self.state.is_alive() {
14874 anyhow::bail!("you are dead");
14875 }
14876 let Some(mode) = self.state.relocate_mode.clone() else {
14877 anyhow::bail!("not relocating");
14878 };
14879 let (px, py) = self.state.player_position();
14880 let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
14881 if dist > 8.0 {
14882 anyhow::bail!("destination too far (max 8 m)");
14883 }
14884 self.seq += 1;
14885 self.session
14886 .submit_intent(Intent::MovePlacedContainer {
14887 entity_id: self.state.entity_id,
14888 container_id: mode.container_id.clone(),
14889 x: mode.cursor_x,
14890 y: mode.cursor_y,
14891 seq: self.seq,
14892 })
14893 .await?;
14894 self.state.intents_sent += 1;
14895 self.state.relocate_mode = None;
14896 self.state.push_log(format!("Moving {}…", mode.label));
14897 Ok(())
14898 }
14899
14900 pub fn claim_set_preset(&mut self, w: u32, h: u32) {
14901 let Some(mode) = self.state.claim_mode.as_mut() else {
14902 return;
14903 };
14904 mode.width_m = w.max(1);
14905 mode.height_m = h.max(1);
14906 }
14907
14908 pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
14909 let Some(mode) = self.state.claim_mode.as_mut() else {
14910 return;
14911 };
14912 let w = (mode.width_m as i32 + dw).max(1) as u32;
14913 let h = (mode.height_m as i32 + dh).max(1) as u32;
14914 mode.width_m = w;
14915 mode.height_m = h;
14916 }
14917
14918 pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
14920 let Some(mode) = self.state.claim_mode.as_mut() else {
14921 return;
14922 };
14923 let max_x = self.state.world_width_m.max(1.0);
14924 let max_y = self.state.world_height_m.max(1.0);
14925 let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
14926 let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
14927 mode.anchor_x = nx.floor();
14928 mode.anchor_y = ny.floor();
14929 }
14930
14931 pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
14932 if !self.state.is_alive() {
14933 anyhow::bail!("you are dead");
14934 }
14935 let Some(mode) = self.state.claim_mode.clone() else {
14936 anyhow::bail!("not in claim mode");
14937 };
14938 let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
14939 self.state.claim_quote()
14940 else {
14941 anyhow::bail!("cannot quote claim");
14942 };
14943 if !valid {
14944 anyhow::bail!(reason);
14945 }
14946 if !can_afford {
14947 anyhow::bail!(
14948 "not enough copper (need {})",
14949 crate::currency::format_copper(purchase)
14950 );
14951 }
14952 let (x0, y0, x1, y1) = self
14953 .state
14954 .claim_footprint_rect()
14955 .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
14956 let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
14957 self.seq += 1;
14958 self.session
14959 .submit_intent(Intent::BuyPlot {
14960 entity_id: self.state.entity_id,
14961 zone_id: mode.zone_id,
14962 x0,
14963 y0,
14964 x1,
14965 y1,
14966 seq: self.seq,
14967 })
14968 .await?;
14969 self.state.intents_sent += 1;
14970 self.state.claim_mode = None;
14971 self.state.push_log(format!(
14972 "Buying plot for {}",
14973 crate::currency::format_copper(purchase)
14974 ));
14975 Ok(())
14976 }
14977
14978 pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
14979 if !self.state.is_alive() {
14980 anyhow::bail!("you are dead");
14981 }
14982 let zone_id = self
14983 .state
14984 .claim_mode
14985 .as_ref()
14986 .map(|m| m.zone_id.clone())
14987 .or_else(|| {
14988 self.state
14989 .free_property_zone_under_player()
14990 .map(|z| z.id.clone())
14991 })
14992 .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
14993 self.seq += 1;
14994 self.session
14995 .submit_intent(Intent::BuyPlotAllFree {
14996 entity_id: self.state.entity_id,
14997 zone_id,
14998 seq: self.seq,
14999 })
15000 .await?;
15001 self.state.intents_sent += 1;
15002 self.state.claim_mode = None;
15003 self.state.push_log("Claiming largest free plot…");
15004 Ok(())
15005 }
15006
15007 pub async fn confirm_sell_plot_to_crown(&mut self, plot_id: uuid::Uuid) -> anyhow::Result<()> {
15008 if !self.state.is_alive() {
15009 anyhow::bail!("you are dead");
15010 }
15011 self.seq += 1;
15012 self.session
15013 .submit_intent(Intent::SellPlotToCrown {
15014 entity_id: self.state.entity_id,
15015 plot_id,
15016 seq: self.seq,
15017 })
15018 .await?;
15019 self.state.intents_sent += 1;
15020 self.state.sell_plot_confirm = None;
15021 self.state.sell_plot_armed_at = None;
15022 self.state.push_log("Selling plot to the crown…");
15023 Ok(())
15024 }
15025
15026 pub async fn set_plot_farm_public(
15027 &mut self,
15028 plot_id: uuid::Uuid,
15029 public: bool,
15030 public_tax_discount_bps: u32,
15031 ) -> anyhow::Result<()> {
15032 self.seq += 1;
15033 self.session
15034 .submit_intent(Intent::SetPlotFarmPublic {
15035 entity_id: self.state.entity_id,
15036 plot_id,
15037 public,
15038 public_tax_discount_bps,
15039 seq: self.seq,
15040 })
15041 .await?;
15042 self.state.intents_sent += 1;
15043 Ok(())
15044 }
15045
15046 pub async fn plot_farm_allow_upsert(
15047 &mut self,
15048 plot_id: uuid::Uuid,
15049 character_id: Option<uuid::Uuid>,
15050 character_name: String,
15051 tax_discount_bps: u32,
15052 ) -> anyhow::Result<()> {
15053 self.seq += 1;
15054 self.session
15055 .submit_intent(Intent::PlotFarmAllowUpsert {
15056 entity_id: self.state.entity_id,
15057 plot_id,
15058 character_id,
15059 character_name,
15060 tax_discount_bps,
15061 seq: self.seq,
15062 })
15063 .await?;
15064 self.state.intents_sent += 1;
15065 Ok(())
15066 }
15067
15068 pub async fn plot_farm_allow_remove(
15069 &mut self,
15070 plot_id: uuid::Uuid,
15071 character_id: uuid::Uuid,
15072 ) -> anyhow::Result<()> {
15073 self.seq += 1;
15074 self.session
15075 .submit_intent(Intent::PlotFarmAllowRemove {
15076 entity_id: self.state.entity_id,
15077 plot_id,
15078 character_id,
15079 seq: self.seq,
15080 })
15081 .await?;
15082 self.state.intents_sent += 1;
15083 Ok(())
15084 }
15085
15086 pub fn open_farm_access_panel(&mut self) {
15087 let Some(plot) = self.state.my_plot_under_player() else {
15088 self.state
15089 .push_log("Stand on your deed plot to manage farm access");
15090 return;
15091 };
15092 self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
15093 self.state.farm_access_index = 0;
15094 self.state.show_farm_access = true;
15095 }
15096
15097 pub fn close_farm_access_panel(&mut self) {
15098 self.state.show_farm_access = false;
15099 self.state.farm_access_name_draft.clear();
15100 self.state.farm_access_index = 0;
15101 }
15102
15103 pub fn farm_access_move(&mut self, delta: i32) {
15104 let n = self.farm_access_row_count().max(1);
15105 let idx = self.state.farm_access_index as i32 + delta;
15106 self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
15107 }
15108
15109 pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
15110 let Some(plot) = self.state.my_plot_under_player() else {
15111 return vec![FarmAccessRow::PublicToggle];
15112 };
15113 let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
15114 for g in &plot.farm_allow {
15115 rows.push(FarmAccessRow::AllowRemove {
15116 character_id: g.character_id,
15117 label: if g.character_label.trim().is_empty() {
15118 g.character_id.to_string()[..8].to_string()
15119 } else {
15120 g.character_label.clone()
15121 },
15122 tax_discount_bps: g.tax_discount_bps,
15123 });
15124 }
15125 for e in &self.state.entities {
15126 if e.id == self.state.entity_id || e.label.trim().is_empty() {
15127 continue;
15128 }
15129 if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
15130 continue;
15131 }
15132 if self
15133 .state
15134 .npcs
15135 .iter()
15136 .any(|n| n.id == e.label || n.label == e.label)
15137 {
15138 continue;
15139 }
15140 if plot
15141 .farm_allow
15142 .iter()
15143 .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
15144 {
15145 continue;
15146 }
15147 rows.push(FarmAccessRow::NearbyAdd {
15148 name: e.label.clone(),
15149 });
15150 }
15151 rows
15152 }
15153
15154 pub fn farm_access_row_count(&self) -> usize {
15155 self.farm_access_rows().len().max(1)
15156 }
15157
15158 pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
15159 let Some(plot) = self.state.my_plot_under_player().cloned() else {
15160 self.close_farm_access_panel();
15161 return Ok(());
15162 };
15163 let rows = self.farm_access_rows();
15164 let Some(row) = rows.get(self.state.farm_access_index) else {
15165 return Ok(());
15166 };
15167 match row {
15168 FarmAccessRow::PublicToggle => {
15169 self.set_plot_farm_public(
15170 plot.plot_id,
15171 !plot.farm_public,
15172 plot.public_tax_discount_bps,
15173 )
15174 .await
15175 }
15176 FarmAccessRow::PublicDiscount => Ok(()),
15177 FarmAccessRow::AllowRemove { character_id, .. } => {
15178 self.plot_farm_allow_remove(plot.plot_id, *character_id)
15179 .await
15180 }
15181 FarmAccessRow::NearbyAdd { name } => {
15182 let disc = self
15183 .state
15184 .farm_access_discount_bps
15185 .max(plot.public_tax_discount_bps);
15186 self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
15187 .await
15188 }
15189 }
15190 }
15191
15192 pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
15193 let Some(plot) = self.state.my_plot_under_player().cloned() else {
15194 return Ok(());
15195 };
15196 let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
15197 self.state.farm_access_discount_bps = next;
15198 self.state.farm_access_index = 1;
15199 self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
15200 .await
15201 }
15202
15203 pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
15205 if self.state.farmable_plot_under_player().is_none() {
15206 anyhow::bail!("stand on a farmable plot to cultivate");
15207 }
15208 let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
15209 let (px, py) = self.state.player_position();
15210 if self
15211 .state
15212 .terrain_at(px, py)
15213 .is_some_and(|k| k == TerrainKindView::Tilled)
15214 {
15215 anyhow::bail!("already tilled — stand on bare soil and press c");
15216 }
15217 anyhow::bail!("cannot till this cell — move onto soil on your plot");
15218 };
15219 self.cultivate_at(tx, ty).await
15220 }
15221
15222 pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
15224 if self.state.farmable_plot_under_player().is_none() {
15225 anyhow::bail!("stand on a farmable plot to plant");
15226 }
15227 if !self.state.underfoot_free_tilled_plant_slot() {
15228 anyhow::bail!("stand on empty tilled soil and press p");
15229 }
15230 let seeds = self.state.farm_seed_entries();
15231 if seeds.is_empty() {
15232 anyhow::bail!("no seeds in inventory — buy seeds from Eli");
15233 }
15234 if seeds.len() == 1 {
15235 return self.plant_seeds(seeds[0].0.clone(), 1).await;
15236 }
15237 self.open_plant_menu();
15238 Ok(())
15239 }
15240
15241 pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
15243 let Some(plot) = self.state.my_plot_under_player() else {
15244 anyhow::bail!("stand on your plot to build");
15245 };
15246 if plot.building_id.is_some() {
15247 anyhow::bail!("this plot already has a building");
15248 }
15249 let building_now = self
15250 .state
15251 .timed_channel
15252 .as_ref()
15253 .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
15254 if !building_now && self.state.building_materials.is_empty() {
15255 anyhow::bail!("no building materials loaded — wait a moment and try again");
15256 }
15257 self.state.show_plot_build_menu = true;
15258 self.state.show_craft_menu = false;
15259 self.state.show_shop_menu = false;
15260 self.state.shop_catalog = None;
15261 self.state.show_stats = false;
15262 self.state.show_inventory_menu = false;
15263 self.state.plot_build_focus_wall = true;
15264 let walls = self.state.plot_build_wall_options().len();
15265 let roofs = self.state.plot_build_roof_options().len();
15266 if walls > 0 {
15267 self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
15268 } else {
15269 self.state.plot_build_wall_index = 0;
15270 }
15271 if roofs > 0 {
15272 self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
15273 } else {
15274 self.state.plot_build_roof_index = 0;
15275 }
15276 Ok(())
15277 }
15278
15279 pub fn close_plot_build_menu(&mut self) {
15280 self.state.show_plot_build_menu = false;
15281 }
15282
15283 pub fn plot_build_menu_move(&mut self, delta: i32) {
15284 let walls = self.state.plot_build_wall_options();
15285 let roofs = self.state.plot_build_roof_options();
15286 if self.state.plot_build_focus_wall {
15287 if walls.is_empty() {
15288 return;
15289 }
15290 let n = walls.len() as i32;
15291 let cur = self.state.plot_build_wall_index as i32;
15292 self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
15293 } else {
15294 if roofs.is_empty() {
15295 return;
15296 }
15297 let n = roofs.len() as i32;
15298 let cur = self.state.plot_build_roof_index as i32;
15299 self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
15300 }
15301 }
15302
15303 pub fn plot_build_menu_toggle_focus(&mut self) {
15304 self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
15305 }
15306
15307 pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
15309 let wall = self
15310 .state
15311 .plot_build_selected_wall()
15312 .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
15313 .id
15314 .clone();
15315 let roof = self
15316 .state
15317 .plot_build_selected_roof()
15318 .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
15319 .id
15320 .clone();
15321 self.start_plot_build(&wall, &roof).await
15323 }
15324
15325 pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
15327 self.seq += 1;
15328 self.session
15329 .submit_intent(Intent::CancelPlotBuild {
15330 entity_id: self.state.entity_id,
15331 seq: self.seq,
15332 })
15333 .await?;
15334 self.state.intents_sent += 1;
15335 Ok(())
15336 }
15337
15338 pub async fn start_plot_build(
15340 &mut self,
15341 wall_material_id: &str,
15342 roof_material_id: &str,
15343 ) -> anyhow::Result<()> {
15344 let Some(plot) = self.state.my_plot_under_player() else {
15345 anyhow::bail!("stand on your plot to build");
15346 };
15347 if plot.building_id.is_some() {
15348 anyhow::bail!("this plot already has a building");
15349 }
15350 let plot_id = plot.plot_id;
15351 self.seq += 1;
15352 self.session
15353 .submit_intent(Intent::StartPlotBuild {
15354 entity_id: self.state.entity_id,
15355 plot_id,
15356 wall_material_id: wall_material_id.to_string(),
15357 roof_material_id: roof_material_id.to_string(),
15358 seq: self.seq,
15359 })
15360 .await?;
15361 self.state.intents_sent += 1;
15362 Ok(())
15363 }
15364
15365 pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
15367 let (px, py) = self.state.player_position();
15368 let mut best: Option<(f32, String, bool)> = None;
15369 for d in &self.state.doors {
15370 if d.lock_id.is_none() {
15371 continue;
15372 }
15373 let dist = (d.x - px).hypot(d.y - py);
15374 if dist > DOOR_INTERACTION_RADIUS_M {
15375 continue;
15376 }
15377 if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
15378 best = Some((dist, d.id.clone(), d.locked));
15379 }
15380 }
15381 let Some((_, door_id, locked_now)) = best else {
15382 anyhow::bail!("no lockable door nearby");
15383 };
15384 let locked = !locked_now;
15385 self.seq += 1;
15386 self.session
15387 .submit_intent(Intent::SetDoorLocked {
15388 entity_id: self.state.entity_id,
15389 door_id,
15390 locked,
15391 seq: self.seq,
15392 })
15393 .await?;
15394 self.state.intents_sent += 1;
15395 Ok(())
15396 }
15397
15398 pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
15400 if !self.state.is_alive() {
15401 anyhow::bail!("you are dead");
15402 }
15403 if self.state.effective_inside_building().is_some() {
15404 anyhow::bail!("already inside");
15405 }
15406 let (px, py) = self.state.player_position();
15407 let mut best: Option<(f32, String)> = None;
15408 for d in &self.state.doors {
15409 if !d.open || d.locked {
15410 continue;
15411 }
15412 let player_house = self
15413 .state
15414 .buildings
15415 .iter()
15416 .find(|b| b.id == d.building_id)
15417 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15418 if !player_house {
15419 continue;
15420 }
15421 let dist = (d.x - px).hypot(d.y - py);
15422 if dist > DOOR_INTERACTION_RADIUS_M {
15423 continue;
15424 }
15425 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15426 best = Some((dist, d.id.clone()));
15427 }
15428 }
15429 let Some((_, door_id)) = best else {
15430 anyhow::bail!("no open house door nearby — open with f first");
15431 };
15432 self.seq += 1;
15433 self.session
15434 .submit_intent(Intent::EnterBuildingDoor {
15435 entity_id: self.state.entity_id,
15436 door_id,
15437 seq: self.seq,
15438 })
15439 .await?;
15440 self.state.intents_sent += 1;
15441 Ok(())
15442 }
15443
15444 pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
15447 if !self.state.is_alive() {
15448 anyhow::bail!("you are dead");
15449 }
15450 let Some(bid) = self.state.effective_inside_building() else {
15451 anyhow::bail!("not inside a building");
15452 };
15453 let (px, py) = self.state.player_position();
15454 let mut best: Option<(f32, String)> = None;
15455 for d in &self.state.doors {
15456 if d.building_id != bid || d.portal.is_none() {
15457 continue;
15458 }
15459 let player_house = self
15460 .state
15461 .buildings
15462 .iter()
15463 .find(|b| b.id == d.building_id)
15464 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15465 if !player_house {
15466 continue;
15467 }
15468 let dist = (d.x - px).hypot(d.y - py);
15469 if dist > 1.5 {
15470 continue;
15471 }
15472 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15473 best = Some((dist, d.id.clone()));
15474 }
15475 }
15476 let Some((_, door_id)) = best else {
15477 anyhow::bail!("stand by the door to exit");
15478 };
15479 self.seq += 1;
15480 self.session
15481 .submit_intent(Intent::ExitBuildingDoor {
15482 entity_id: self.state.entity_id,
15483 door_id,
15484 seq: self.seq,
15485 })
15486 .await?;
15487 self.state.intents_sent += 1;
15488 Ok(())
15489 }
15490
15491 pub async fn confirm_interior_edit(
15493 &mut self,
15494 building_id: String,
15495 rooms: Vec<flatland_protocol::InteriorRoomEdit>,
15496 room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
15497 ) -> anyhow::Result<()> {
15498 self.seq += 1;
15499 self.session
15500 .submit_intent(Intent::ConfirmInteriorEdit {
15501 entity_id: self.state.entity_id,
15502 building_id,
15503 rooms,
15504 room_doors,
15505 seq: self.seq,
15506 })
15507 .await?;
15508 self.state.intents_sent += 1;
15509 Ok(())
15510 }
15511
15512 pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
15513 if !self.state.is_alive() {
15514 anyhow::bail!("you are dead");
15515 }
15516 self.seq += 1;
15517 self.session
15518 .submit_intent(Intent::Cultivate {
15519 entity_id: self.state.entity_id,
15520 x,
15521 y,
15522 seq: self.seq,
15523 })
15524 .await?;
15525 self.state.intents_sent += 1;
15526 Ok(())
15527 }
15528
15529 pub async fn plant_seeds(
15530 &mut self,
15531 seed_template_id: String,
15532 quantity: u32,
15533 ) -> anyhow::Result<()> {
15534 if !self.state.is_alive() {
15535 anyhow::bail!("you are dead");
15536 }
15537 self.seq += 1;
15538 self.session
15539 .submit_intent(Intent::PlantSeeds {
15540 entity_id: self.state.entity_id,
15541 seed_template_id: seed_template_id.clone(),
15542 quantity,
15543 seq: self.seq,
15544 })
15545 .await?;
15546 self.state.intents_sent += 1;
15547 self.state
15548 .push_log(format!("Planting {quantity}× {seed_template_id}…"));
15549 Ok(())
15550 }
15551
15552 pub fn open_plant_menu(&mut self) {
15553 if self.state.farm_seed_entries().is_empty() {
15554 self.state.push_log("No seeds in inventory to plant");
15555 return;
15556 }
15557 self.state.show_plant_menu = true;
15558 self.state.plant_menu_index = 0;
15559 self.state.plant_quantity = 1;
15560 self.state.clamp_plant_menu();
15561 }
15562
15563 pub fn close_plant_menu(&mut self) {
15564 self.state.show_plant_menu = false;
15565 }
15566
15567 pub fn plant_menu_move(&mut self, delta: i32) {
15568 let n = self.state.farm_seed_entries().len();
15569 if n == 0 {
15570 return;
15571 }
15572 let idx = self.state.plant_menu_index as i32 + delta;
15573 self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
15574 self.state.clamp_plant_menu();
15575 }
15576
15577 pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
15578 let next = self.state.plant_quantity as i32 + delta;
15579 self.state.plant_quantity = next.max(1) as u32;
15580 self.state.clamp_plant_menu();
15581 }
15582
15583 pub fn plant_menu_set_quantity_max(&mut self) {
15584 if let Some((_, max, _)) = self.state.plant_menu_selection() {
15585 self.state.plant_quantity = max;
15586 }
15587 self.state.clamp_plant_menu();
15588 }
15589
15590 pub fn plant_menu_set_quantity_min(&mut self) {
15591 self.state.plant_quantity = 1;
15592 self.state.clamp_plant_menu();
15593 }
15594
15595 pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
15596 let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
15597 self.close_plant_menu();
15598 anyhow::bail!("no seeds to plant");
15599 };
15600 self.close_plant_menu();
15601 self.plant_seeds(seed, qty).await?;
15602 self.state.push_log(format!("Planted {qty}× {label}"));
15603 Ok(())
15604 }
15605
15606 pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
15609 if !self.state.is_alive() {
15610 anyhow::bail!("you are dead");
15611 }
15612 let binding = self
15613 .state
15614 .hotbar_ability(slot)
15615 .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
15616 .to_string();
15617 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
15618 let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
15619 if qty == 0 {
15620 anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
15621 }
15622 return self.use_item(template_id).await;
15623 }
15624 let ability_id = binding;
15625 if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
15626 return self
15627 .cast_ability(&ability_id, Some(self.state.entity_id))
15628 .await;
15629 }
15630 let is_heal = ability_id == "heal_touch"
15631 || self
15632 .state
15633 .ability_meta
15634 .get(&ability_id)
15635 .map(|meta| meta.is_heal)
15636 .unwrap_or(false);
15637 let target = if is_heal {
15638 Some(
15639 self.state
15640 .target_for_slot(2)
15641 .unwrap_or(self.state.entity_id),
15642 )
15643 } else {
15644 self.state
15645 .target_for_slot(1)
15646 .or_else(|| self.state.target_for_slot(2))
15647 };
15648 let Some(target_id) = target else {
15649 anyhow::bail!("no target — Tab to select, then press the hotbar key");
15650 };
15651 self.cast_ability(&ability_id, Some(target_id)).await
15652 }
15653
15654 pub async fn set_hotbar_slot(
15657 &mut self,
15658 slot: u8,
15659 ability_id: Option<&str>,
15660 ) -> anyhow::Result<()> {
15661 if !self.state.is_alive() {
15662 anyhow::bail!("you are dead");
15663 }
15664 if !(1..=9).contains(&slot) {
15665 anyhow::bail!("hotbar slot must be 1–9");
15666 }
15667 let ability_id = ability_id
15668 .map(str::trim)
15669 .filter(|id| !id.is_empty())
15670 .map(str::to_string);
15671 self.seq += 1;
15672 self.session
15673 .submit_intent(Intent::SetHotbarSlot {
15674 entity_id: self.state.entity_id,
15675 slot,
15676 ability_id: ability_id.clone(),
15677 seq: self.seq,
15678 })
15679 .await?;
15680 self.state.intents_sent += 1;
15681 let idx = (slot - 1) as usize;
15682 if self.state.hotbar.len() < 9 {
15683 self.state.hotbar.resize(9, None);
15684 }
15685 if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
15686 *slot_mut = ability_id.clone();
15687 }
15688 match ability_id {
15689 Some(id) => {
15690 let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
15691 format!("use {tid}")
15692 } else {
15693 id
15694 };
15695 self.state.push_log(format!("Hotbar {slot} → {label}"))
15696 }
15697 None => self.state.push_log(format!("Hotbar {slot} cleared")),
15698 }
15699 Ok(())
15700 }
15701
15702 pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
15703 self.state.npc_verb_options()
15704 }
15705
15706 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
15707 let Some(npc_id) = self.state.npc_verb_target.clone() else {
15708 return Ok(());
15709 };
15710 let options = self.npc_verb_options();
15711 let choice = options
15712 .get(self.state.npc_verb_index)
15713 .cloned()
15714 .unwrap_or_else(GameState::talk_choice);
15715 match choice.action {
15716 NpcVerbAction::QuestGive { quest_id } => {
15717 self.submit_npc_quest_turn_in(&npc_id, Some(&quest_id))
15718 .await?;
15719 self.state.show_npc_verb_menu = false;
15720 }
15721 NpcVerbAction::Talk => {
15722 self.open_npc_talk(&npc_id, None).await?;
15723 }
15724 NpcVerbAction::QuestTalk { quest_id } => {
15725 self.open_npc_talk(&npc_id, Some(&quest_id)).await?;
15726 }
15727 NpcVerbAction::Trade
15728 | NpcVerbAction::Bank
15729 | NpcVerbAction::Storage
15730 | NpcVerbAction::Market => {
15731 self.seq += 1;
15732 self.session
15733 .submit_intent(Intent::Interact {
15734 entity_id: self.state.entity_id,
15735 target_id: npc_id,
15736 seq: self.seq,
15737 })
15738 .await?;
15739 self.state.intents_sent += 1;
15740 }
15741 }
15742 Ok(())
15743 }
15744
15745 async fn open_npc_talk(&mut self, npc_id: &str, quest_id: Option<&str>) -> anyhow::Result<()> {
15746 self.seq += 1;
15747 self.session
15748 .submit_intent(Intent::NpcTalkOpen {
15749 entity_id: self.state.entity_id,
15750 npc_id: npc_id.to_string(),
15751 quest_id: quest_id.map(str::to_string),
15752 seq: self.seq,
15753 })
15754 .await?;
15755 self.state.intents_sent += 1;
15756 Ok(())
15757 }
15758
15759 async fn submit_npc_quest_turn_in(
15760 &mut self,
15761 npc_id: &str,
15762 quest_id: Option<&str>,
15763 ) -> anyhow::Result<()> {
15764 let catalog_ref = self.state.npc_quest_catalog_ref(npc_id);
15765 let pending: Vec<(String, u32, String)> = self
15766 .state
15767 .quest_log
15768 .iter()
15769 .filter(|q| {
15770 q.status == flatland_protocol::QuestStatusView::Active
15771 && quest_id.is_none_or(|id| q.quest_id == id)
15772 })
15773 .flat_map(|q| q.objectives.iter())
15774 .filter(|o| {
15775 !o.done
15776 && o.kind == "give_item"
15777 && o.npc_ref.as_deref() == Some(catalog_ref.as_str())
15778 })
15779 .filter_map(|o| {
15780 let template = o.item_template.clone()?;
15781 let remaining = o.required.saturating_sub(o.current);
15782 if remaining == 0 {
15783 return None;
15784 }
15785 Some((template, remaining, o.label.clone()))
15786 })
15787 .collect();
15788 if pending.is_empty() {
15789 self.state.push_log("Nothing to turn in here.");
15790 return Ok(());
15791 }
15792 let mut sent = 0u32;
15793 for (template, remaining, label) in pending {
15794 let held = self.state.count_inventory_template(&template);
15795 let qty = remaining.min(held);
15796 if qty == 0 {
15797 self.state.push_log(format!("Need {label}"));
15798 continue;
15799 }
15800 self.seq += 1;
15801 self.session
15802 .submit_intent(Intent::QuestGiveItem {
15803 entity_id: self.state.entity_id,
15804 npc_id: npc_id.to_string(),
15805 template_id: template,
15806 quantity: qty,
15807 seq: self.seq,
15808 })
15809 .await?;
15810 self.state.intents_sent += 1;
15811 sent += 1;
15812 }
15813 if sent > 0 {
15814 self.state.push_log("Turning in quest items.");
15815 }
15816 Ok(())
15817 }
15818
15819 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
15820 let Some(chat) = self.state.npc_chat.clone() else {
15821 return Ok(());
15822 };
15823 let message = chat.input.trim().to_string();
15824 if message.is_empty() || chat.pending {
15825 return Ok(());
15826 }
15827 if let Some(c) = self.state.npc_chat.as_mut() {
15828 c.lines.push(format!("You: {message}"));
15829 c.input.clear();
15830 c.pending = true;
15831 }
15832 self.seq += 1;
15833 self.session
15834 .submit_intent(Intent::NpcTalkSay {
15835 entity_id: self.state.entity_id,
15836 npc_id: chat.npc_id,
15837 message,
15838 seq: self.seq,
15839 })
15840 .await?;
15841 self.state.intents_sent += 1;
15842 Ok(())
15843 }
15844
15845 pub async fn npc_talk_topic(&mut self, index: usize) -> anyhow::Result<()> {
15846 let topic = self
15847 .state
15848 .npc_chat
15849 .as_ref()
15850 .and_then(|c| c.suggested_topics.get(index))
15851 .cloned();
15852 let Some(topic) = topic else {
15853 return Ok(());
15854 };
15855 if let Some(c) = self.state.npc_chat.as_mut() {
15856 if c.pending {
15857 return Ok(());
15858 }
15859 c.input = topic;
15860 }
15861 self.npc_talk_send().await
15862 }
15863
15864 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
15865 let return_to_verbs = self.state.npc_verb_target.is_some();
15866 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
15867 self.state.show_npc_chat = false;
15868 if return_to_verbs {
15869 self.state.show_npc_verb_menu = true;
15870 }
15871 return Ok(());
15872 };
15873 self.seq += 1;
15874 self.session
15875 .submit_intent(Intent::NpcTalkClose {
15876 entity_id: self.state.entity_id,
15877 npc_id,
15878 seq: self.seq,
15879 })
15880 .await?;
15881 self.state.intents_sent += 1;
15882 self.state.show_npc_chat = false;
15883 self.state.npc_chat = None;
15884 if return_to_verbs {
15885 self.state.show_npc_verb_menu = true;
15886 self.state.npc_verb_notice = None;
15887 }
15888 Ok(())
15889 }
15890
15891 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
15893 if self.state.show_quest_offer
15894 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
15895 {
15896 self.quest_offer_decline();
15897 return Ok(());
15898 }
15899 if self.state.show_npc_chat {
15900 return self.npc_talk_close().await;
15901 }
15902 if self.state.show_shop_menu {
15903 return self.back_from_shop_menu().await;
15904 }
15905 if self.state.bank_panel.is_some() {
15906 if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
15907 self.bank_transfer_back();
15908 return Ok(());
15909 }
15910 return self.close_bank_panel().await;
15911 }
15912 if self.state.storage_panel.is_some() {
15913 if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
15914 self.storage_ui_back();
15915 return Ok(());
15916 }
15917 return self.close_storage_panel().await;
15918 }
15919 if self.state.market_panel.is_some() {
15920 if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
15921 self.market_ui_back();
15922 return Ok(());
15923 }
15924 if self.state.market_buy_confirm.is_some() {
15925 self.state.market_buy_confirm = None;
15926 return Ok(());
15927 }
15928 return self.close_market_panel().await;
15929 }
15930 if self.state.show_npc_verb_menu {
15931 self.state.show_npc_verb_menu = false;
15932 self.state.npc_verb_target = None;
15933 }
15934 Ok(())
15935 }
15936
15937 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
15938 self.seq += 1;
15939 self.session
15940 .submit_intent(Intent::TestDamage {
15941 entity_id: self.state.entity_id,
15942 amount,
15943 seq: self.seq,
15944 })
15945 .await?;
15946 self.state.intents_sent += 1;
15947 Ok(())
15948 }
15949
15950 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
15951 self.cycle_combat_target_slot(1, reverse).await
15952 }
15953
15954 pub async fn cycle_combat_target_slot(
15955 &mut self,
15956 slot_index: u8,
15957 reverse: bool,
15958 ) -> anyhow::Result<()> {
15959 if !self.state.is_alive() {
15960 anyhow::bail!("you are dead");
15961 }
15962 let candidates = self.state.candidates_for_slot(slot_index);
15963 if candidates.is_empty() {
15964 anyhow::bail!("no targets nearby");
15965 }
15966 let current = self.state.target_for_slot(slot_index);
15967 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
15968 let next_idx = match idx {
15969 None => 0,
15970 Some(i) if reverse => {
15971 if i == 0 {
15972 candidates.len() - 1
15973 } else {
15974 i - 1
15975 }
15976 }
15977 Some(i) => (i + 1) % candidates.len(),
15978 };
15979 if idx == Some(next_idx) && candidates.len() == 1 {
15980 self.clear_combat_target_slot(slot_index).await?;
15981 return Ok(());
15982 }
15983 let (target_id, label) = candidates[next_idx].clone();
15984 self.set_combat_target_slot(slot_index, target_id, &label)
15985 .await
15986 }
15987
15988 pub async fn set_combat_target_slot(
15989 &mut self,
15990 slot_index: u8,
15991 target_id: EntityId,
15992 label: &str,
15993 ) -> anyhow::Result<()> {
15994 if !self.state.is_alive() {
15995 anyhow::bail!("you are dead");
15996 }
15997 self.seq += 1;
15998 self.session
15999 .submit_intent(Intent::SetTargetSlot {
16000 entity_id: self.state.entity_id,
16001 slot_index,
16002 target_id,
16003 seq: self.seq,
16004 })
16005 .await?;
16006 self.state.intents_sent += 1;
16007 if slot_index == 1 {
16008 self.state.combat_target = Some(target_id);
16009 self.state.combat_target_label = Some(label.to_string());
16010 }
16011 self.state
16012 .push_log(format!("Slot {slot_index} target: {label}"));
16013 Ok(())
16014 }
16015
16016 pub async fn set_combat_target(
16017 &mut self,
16018 target_id: EntityId,
16019 label: &str,
16020 ) -> anyhow::Result<()> {
16021 self.set_combat_target_slot(1, target_id, label).await
16022 }
16023
16024 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
16025 if slot_index == 1 && self.state.combat_target.is_none() {
16026 return Ok(());
16027 }
16028 self.seq += 1;
16029 self.session
16030 .submit_intent(Intent::ClearTargetSlot {
16031 entity_id: self.state.entity_id,
16032 slot_index,
16033 seq: self.seq,
16034 })
16035 .await?;
16036 if slot_index == 1 {
16037 self.state.combat_target = None;
16038 self.state.combat_target_label = None;
16039 }
16040 self.state.intents_sent += 1;
16041 self.state
16042 .push_log(format!("Slot {slot_index} target cleared"));
16043 Ok(())
16044 }
16045
16046 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
16047 self.clear_combat_target_slot(1).await
16048 }
16049
16050 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
16051 if !self.state.is_alive() {
16052 anyhow::bail!("you are dead");
16053 }
16054 self.seq += 1;
16055 self.session
16056 .submit_intent(Intent::AdvanceRotation {
16057 entity_id: self.state.entity_id,
16058 slot_index,
16059 seq: self.seq,
16060 })
16061 .await?;
16062 self.state.intents_sent += 1;
16063 Ok(())
16064 }
16065
16066 pub async fn assign_slot_preset(
16067 &mut self,
16068 slot_index: u8,
16069 preset_id: &str,
16070 ) -> anyhow::Result<()> {
16071 if !self.state.is_alive() {
16072 anyhow::bail!("you are dead");
16073 }
16074 self.seq += 1;
16075 self.session
16076 .submit_intent(Intent::AssignSlotPreset {
16077 entity_id: self.state.entity_id,
16078 slot_index,
16079 preset_id: preset_id.to_string(),
16080 seq: self.seq,
16081 })
16082 .await?;
16083 self.state.intents_sent += 1;
16084 if let Some(slot) = self
16085 .state
16086 .combat_slots
16087 .iter_mut()
16088 .find(|s| s.slot_index == slot_index)
16089 {
16090 slot.preset_id = Some(preset_id.to_string());
16091 if let Some(preset) = self
16092 .state
16093 .rotation_presets
16094 .iter()
16095 .find(|p| p.id == preset_id)
16096 {
16097 slot.preset_label = Some(preset.label.clone());
16098 slot.rotation = preset.abilities.clone();
16099 slot.rotation_index = 0;
16100 }
16101 }
16102 self.state
16103 .push_log(format!("T{slot_index} loadout → {preset_id}"));
16104 Ok(())
16105 }
16106
16107 pub async fn cast_ability(
16108 &mut self,
16109 ability_id: &str,
16110 target_id: Option<EntityId>,
16111 ) -> anyhow::Result<()> {
16112 if !self.state.is_alive() {
16113 anyhow::bail!("you are dead");
16114 }
16115 let allows_ground = self.state.ability_allows_ground(ability_id);
16116 let requires_ground = self.state.ability_requires_ground(ability_id);
16117 if requires_ground && self.state.ground_target.is_none() {
16118 anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
16119 }
16120 let (resolved_target_id, target_point) = if allows_ground {
16121 if let Some((x, y, z)) = self.state.ground_target {
16122 (
16123 target_id.unwrap_or(self.state.entity_id),
16124 Some(flatland_protocol::AimPoint { x, y, z }),
16125 )
16126 } else {
16127 (
16128 target_id
16129 .or_else(|| self.state.target_for_slot(2))
16130 .or_else(|| self.state.target_for_slot(1))
16131 .unwrap_or(self.state.entity_id),
16132 None,
16133 )
16134 }
16135 } else {
16136 (
16137 target_id
16138 .or_else(|| self.state.target_for_slot(2))
16139 .or_else(|| self.state.target_for_slot(1))
16140 .unwrap_or(self.state.entity_id),
16141 None,
16142 )
16143 };
16144 self.seq += 1;
16145 self.session
16146 .submit_intent(Intent::Cast {
16147 entity_id: self.state.entity_id,
16148 ability_id: ability_id.to_string(),
16149 target_id: resolved_target_id,
16150 target_point,
16151 seq: self.seq,
16152 })
16153 .await?;
16154 self.state.intents_sent += 1;
16155 match target_point {
16156 Some(point) => self.state.push_log(format!(
16157 "Cast {ability_id} → ({:.1}, {:.1})",
16158 point.x, point.y
16159 )),
16160 None => self
16161 .state
16162 .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
16163 }
16164 Ok(())
16165 }
16166
16167 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
16168 self.seq += 1;
16169 self.session
16170 .submit_intent(Intent::UpsertRotationPreset {
16171 entity_id: self.state.entity_id,
16172 preset: preset.clone(),
16173 seq: self.seq,
16174 })
16175 .await?;
16176 self.state.intents_sent += 1;
16177 if let Some(existing) = self
16178 .state
16179 .rotation_presets
16180 .iter_mut()
16181 .find(|p| p.id == preset.id)
16182 {
16183 *existing = preset.clone();
16184 } else {
16185 self.state.rotation_presets.push(preset.clone());
16186 }
16187 for slot in &mut self.state.combat_slots {
16188 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
16189 slot.preset_label = Some(preset.label.clone());
16190 slot.rotation = preset.abilities.clone();
16191 }
16192 }
16193 self.state
16194 .push_log(format!("Saved rotation: {}", preset.label));
16195 Ok(())
16196 }
16197
16198 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
16199 self.seq += 1;
16200 self.session
16201 .submit_intent(Intent::DeleteRotationPreset {
16202 entity_id: self.state.entity_id,
16203 preset_id: preset_id.to_string(),
16204 seq: self.seq,
16205 })
16206 .await?;
16207 self.state.intents_sent += 1;
16208 self.state.rotation_presets.retain(|p| p.id != preset_id);
16209 for slot in &mut self.state.combat_slots {
16210 if slot.preset_id.as_deref() == Some(preset_id) {
16211 slot.preset_id = None;
16212 slot.preset_label = None;
16213 slot.rotation.clear();
16214 slot.rotation_index = 0;
16215 }
16216 }
16217 self.state
16218 .push_log(format!("Deleted rotation: {preset_id}"));
16219 Ok(())
16220 }
16221
16222 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
16223 if !self.state.is_alive() {
16224 anyhow::bail!("you are dead");
16225 }
16226 let enabled = !self
16227 .state
16228 .combat_slots
16229 .iter()
16230 .find(|s| s.slot_index == slot_index)
16231 .map(|s| s.auto_enabled)
16232 .unwrap_or(false);
16233 self.seq += 1;
16234 self.session
16235 .submit_intent(Intent::SetAutoAttack {
16236 entity_id: self.state.entity_id,
16237 slot_index,
16238 enabled,
16239 seq: self.seq,
16240 })
16241 .await?;
16242 if slot_index == 1 {
16243 self.state.auto_attack = enabled;
16244 }
16245 self.state.intents_sent += 1;
16246 self.state.push_log(format!(
16247 "T{slot_index} auto {}",
16248 if enabled { "ON" } else { "OFF" }
16249 ));
16250 Ok(())
16251 }
16252
16253 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
16254 if !self.state.connected {
16255 anyhow::bail!("not connected");
16256 }
16257 if !self.state.is_alive() {
16258 anyhow::bail!("you are dead");
16259 }
16260 let (px, py) = self.state.player_position();
16261 if self
16262 .state
16263 .ground_drops
16264 .iter()
16265 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
16266 {
16267 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
16268 }
16269 self.seq += 1;
16270 self.session
16271 .submit_intent(Intent::Pickup {
16272 entity_id: self.state.entity_id,
16273 drop_id: None,
16274 seq: self.seq,
16275 })
16276 .await?;
16277 self.state.intents_sent += 1;
16278 self.state.push_audio(crate::social::AudioCue::LootPickup);
16279 Ok(())
16280 }
16281
16282 pub async fn dodge(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16283 if !self.state.is_alive() {
16284 anyhow::bail!("you are dead");
16285 }
16286 self.seq += 1;
16288 self.session
16289 .submit_intent(Intent::Dodge {
16290 entity_id: self.state.entity_id,
16291 forward,
16292 strafe,
16293 seq: self.seq,
16294 })
16295 .await?;
16296 self.state.intents_sent += 1;
16297 self.state.push_log("Dodge!");
16298 self.state.push_audio(crate::social::AudioCue::CombatDodge);
16299 Ok(())
16300 }
16301
16302 pub async fn lunge(&mut self) -> anyhow::Result<()> {
16303 if !self.state.is_alive() {
16304 anyhow::bail!("you are dead");
16305 }
16306 let (forward, strafe) = self.last_move_axes();
16307 self.seq += 1;
16308 self.session
16309 .submit_intent(Intent::Lunge {
16310 entity_id: self.state.entity_id,
16311 forward,
16312 strafe,
16313 seq: self.seq,
16314 })
16315 .await?;
16316 self.state.intents_sent += 1;
16317 self.state.push_log("Lunge!");
16318 Ok(())
16319 }
16320
16321 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16322 if !self.state.is_alive() {
16323 anyhow::bail!("you are dead");
16324 }
16325 self.seq += 1;
16326 self.session
16327 .submit_intent(Intent::DirectionalJump {
16328 entity_id: self.state.entity_id,
16329 forward,
16330 strafe,
16331 seq: self.seq,
16332 })
16333 .await?;
16334 self.state.intents_sent += 1;
16335 self.state.push_log("Jump!");
16336 Ok(())
16337 }
16338
16339 pub fn last_move_axes(&self) -> (f32, f32) {
16341 (self.last_move_forward, self.last_move_strafe)
16342 }
16343
16344 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
16345 if !self.state.is_alive() {
16346 anyhow::bail!("you are dead");
16347 }
16348 self.seq += 1;
16349 self.session
16350 .submit_intent(Intent::Block {
16351 entity_id: self.state.entity_id,
16352 enabled,
16353 seq: self.seq,
16354 })
16355 .await?;
16356 self.state.intents_sent += 1;
16357 if enabled {
16358 self.state.push_log("Blocking");
16359 self.state.push_audio(crate::social::AudioCue::CombatBlock);
16360 }
16361 Ok(())
16362 }
16363
16364 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
16365 if !self.state.is_alive() {
16366 anyhow::bail!("you are dead");
16367 }
16368 self.seq += 1;
16369 self.session
16370 .submit_intent(Intent::EquipMainhand {
16371 entity_id: self.state.entity_id,
16372 template_id,
16373 instance_id: None,
16374 seq: self.seq,
16375 })
16376 .await?;
16377 self.state.intents_sent += 1;
16378 Ok(())
16379 }
16380
16381 pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
16383 let idx = self.state.equip_menu_index;
16384 let slots = equip_paperdoll_rows(&self.state);
16385 let Some(row) = slots.get(idx) else {
16386 return Ok(());
16387 };
16388 match row {
16389 EquipPaperdollRow::Body { slot, filled } => {
16390 if *filled {
16391 self.equip_worn(*slot, None).await
16392 } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
16393 self.equip_worn(*slot, Some(inst)).await
16394 } else {
16395 self.state
16396 .push_log(format!("No item for {}", body_slot_label(*slot)));
16397 Ok(())
16398 }
16399 }
16400 EquipPaperdollRow::Mainhand { filled } => {
16401 if *filled {
16402 self.unequip_mainhand().await
16403 } else if let Some(tid) = first_inventory_weapon(&self.state) {
16404 self.equip_mainhand(Some(tid)).await
16405 } else {
16406 self.state.push_log("No weapon in inventory".to_string());
16407 Ok(())
16408 }
16409 }
16410 EquipPaperdollRow::Offhand { filled, locked } => {
16411 if *locked {
16412 self.state
16413 .push_log("Offhand locked — two-handed weapon equipped".to_string());
16414 Ok(())
16415 } else if *filled {
16416 self.unequip_offhand().await
16417 } else if let Some(tid) = first_inventory_offhand(&self.state) {
16418 self.equip_offhand(Some(tid)).await
16419 } else {
16420 self.state
16421 .push_log("No offhand item in inventory".to_string());
16422 Ok(())
16423 }
16424 }
16425 }
16426 }
16427
16428 pub async fn say(
16429 &mut self,
16430 channel: flatland_protocol::ChatChannel,
16431 text: &str,
16432 ) -> anyhow::Result<()> {
16433 self.say_to(channel, text, None).await
16434 }
16435
16436 pub async fn say_to(
16437 &mut self,
16438 channel: flatland_protocol::ChatChannel,
16439 text: &str,
16440 to_entity: Option<EntityId>,
16441 ) -> anyhow::Result<()> {
16442 self.seq += 1;
16443 self.session
16444 .submit_intent(Intent::Say {
16445 entity_id: self.state.entity_id,
16446 channel,
16447 text: text.to_string(),
16448 to_entity,
16449 seq: self.seq,
16450 })
16451 .await?;
16452 self.state.intents_sent += 1;
16453 Ok(())
16454 }
16455
16456 pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
16457 let Some(peer) = self.state.player_verbs.target_entity else {
16458 return Ok(());
16459 };
16460 let label = self.state.player_verbs.target_label.clone();
16461 let choice = crate::social::PlayerVerbState::options()
16462 .get(self.state.player_verbs.index)
16463 .copied()
16464 .unwrap_or("Whisper");
16465 self.state.player_verbs.close();
16466 match choice {
16467 "Trade" => {
16468 self.seq += 1;
16471 self.session
16472 .submit_intent(Intent::TradeRequest {
16473 entity_id: self.state.entity_id,
16474 peer_entity_id: peer,
16475 seq: self.seq,
16476 })
16477 .await?;
16478 self.state.intents_sent += 1;
16479 self.state.social_chat.push_system(format!(
16480 "Trade request sent to {label} — waiting for accept"
16481 ));
16482 }
16483 "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
16484 _ => self.state.social_chat.focus_nearby(),
16485 }
16486 Ok(())
16487 }
16488
16489 pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
16490 let Some(pending) = self.state.social_chat.pending_trade.take() else {
16491 return Ok(());
16492 };
16493 self.seq += 1;
16494 self.session
16495 .submit_intent(Intent::TradeRespond {
16496 entity_id: self.state.entity_id,
16497 peer_entity_id: pending.from_entity,
16498 accept,
16499 seq: self.seq,
16500 })
16501 .await?;
16502 self.state.intents_sent += 1;
16503 if accept {
16504 self.state
16505 .social_chat
16506 .push_system(format!("Accepted trade with {}", pending.from_name));
16507 } else {
16508 self.state
16509 .social_chat
16510 .push_system(format!("Declined trade with {}", pending.from_name));
16511 }
16512 Ok(())
16513 }
16514
16515 pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
16516 let text = self.state.social_chat.buffer.trim().to_string();
16517 if text.is_empty() {
16518 return Ok(());
16519 }
16520 self.state.social_chat.buffer.clear();
16521 if crate::social::is_chat_slash_line(&text) {
16522 match crate::social::parse_chat_slash(&text) {
16523 Some(cmd) => return self.apply_chat_slash(cmd).await,
16524 None => {
16525 self.state.social_chat.push_system(format!(
16526 "Unknown command — {}",
16527 crate::social::chat_slash_help_text()
16528 ));
16529 return Ok(());
16530 }
16531 }
16532 }
16533 let thread = self.state.social_chat.thread;
16534 let channel = thread.channel();
16535 let to = thread.to_entity();
16536 if let Some(peer) = to {
16537 let label = self.state.social_chat.peer_label.clone();
16538 self.state
16539 .social_chat
16540 .remember_whisper_peer(peer, &label, channel);
16541 }
16542 self.say_to(channel, &text, to).await
16543 }
16544
16545 async fn apply_chat_slash(
16546 &mut self,
16547 cmd: crate::social::ChatSlashCommand,
16548 ) -> anyhow::Result<()> {
16549 use crate::social::{chat_slash_help_text, ChatSlashCommand};
16550 match cmd {
16551 ChatSlashCommand::Help => {
16552 self.state
16553 .social_chat
16554 .push_system(chat_slash_help_text().to_string());
16555 Ok(())
16556 }
16557 ChatSlashCommand::Nearby { message } => {
16558 self.state.social_chat.focus_nearby();
16559 self.state
16560 .social_chat
16561 .push_system("Nearby speech — everyone close can hear");
16562 if let Some(msg) = message {
16563 self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
16564 .await
16565 } else {
16566 Ok(())
16567 }
16568 }
16569 ChatSlashCommand::Reply { message } => {
16570 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16571 self.state
16572 .social_chat
16573 .push_system("No one to reply to — wait for a whisper, or /whisper Name");
16574 return Ok(());
16575 };
16576 let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
16577 self.state
16578 .social_chat
16579 .set_whisper_thread(peer.entity_id, &peer.label, stone);
16580 self.state.social_chat.push_system(format!(
16581 "Replying to {} — type and Enter · /nearby",
16582 peer.label
16583 ));
16584 if let Some(msg) = message {
16585 self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
16586 } else {
16587 Ok(())
16588 }
16589 }
16590 ChatSlashCommand::Whisper { name, message } => {
16591 let (peer_id, label, stone) = if let Some(name) = name {
16592 match self.resolve_whisper_target(&name) {
16593 Ok(t) => t,
16594 Err(err) => {
16595 self.state.social_chat.push_system(err);
16596 return Ok(());
16597 }
16598 }
16599 } else {
16600 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16601 self.state.social_chat.push_system(
16602 "Usage: /whisper Name [message] · or /reply after someone whispers you",
16603 );
16604 return Ok(());
16605 };
16606 (
16607 peer.entity_id,
16608 peer.label,
16609 peer.channel == flatland_protocol::ChatChannel::WhisperStone,
16610 )
16611 };
16612 self.state
16613 .social_chat
16614 .set_whisper_thread(peer_id, &label, stone);
16615 let channel = if stone {
16616 flatland_protocol::ChatChannel::WhisperStone
16617 } else {
16618 flatland_protocol::ChatChannel::Whisper
16619 };
16620 if let Some(msg) = message {
16621 self.state
16622 .social_chat
16623 .push_system(format!("Whisper → {label}"));
16624 self.say_to(channel, &msg, Some(peer_id)).await
16625 } else {
16626 self.state.social_chat.push_system(format!(
16627 "Whispering {label} — type and Enter · Esc / /nearby cancels"
16628 ));
16629 Ok(())
16630 }
16631 }
16632 }
16633 }
16634
16635 fn resolve_whisper_target(&self, name: &str) -> Result<(EntityId, String, bool), String> {
16637 let needle = name.trim().to_ascii_lowercase();
16638 if needle.is_empty() {
16639 return Err("Usage: /whisper Name [message]".into());
16640 }
16641 let mut candidates: Vec<(EntityId, String)> = self
16642 .state
16643 .entities
16644 .iter()
16645 .filter(|e| e.id != self.state.entity_id)
16646 .filter(|e| !e.label.trim().is_empty())
16647 .filter(|e| e.vitals.is_some())
16648 .filter(|e| !self.state.npcs.iter().any(|n| n.id == e.id.to_string()))
16649 .filter(|e| !self.state.hired_workers.iter().any(|w| w.entity_id == e.id))
16650 .map(|e| (e.id, e.label.clone()))
16651 .collect();
16652
16653 if let Some(last) = &self.state.social_chat.last_whisper_peer {
16655 if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
16656 candidates.push((last.entity_id, last.label.clone()));
16657 }
16658 }
16659
16660 let exact: Vec<_> = candidates
16661 .iter()
16662 .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
16663 .cloned()
16664 .collect();
16665 let pool = if exact.len() == 1 {
16666 exact
16667 } else if exact.len() > 1 {
16668 return Err(format!(
16669 "Several players named '{name}' nearby — move closer and try again"
16670 ));
16671 } else {
16672 let starts: Vec<_> = candidates
16673 .iter()
16674 .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
16675 .cloned()
16676 .collect();
16677 if starts.len() == 1 {
16678 starts
16679 } else if starts.len() > 1 {
16680 let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
16681 return Err(format!(
16682 "Ambiguous name '{name}' — matches: {}",
16683 names.join(", ")
16684 ));
16685 } else {
16686 let contains: Vec<_> = candidates
16687 .iter()
16688 .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
16689 .cloned()
16690 .collect();
16691 if contains.len() == 1 {
16692 contains
16693 } else if contains.is_empty() {
16694 return Err(format!(
16695 "No player matching '{name}' in range — get closer or check the spelling"
16696 ));
16697 } else {
16698 let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
16699 return Err(format!(
16700 "Ambiguous name '{name}' — matches: {}",
16701 names.join(", ")
16702 ));
16703 }
16704 }
16705 };
16706
16707 let (id, label) = pool.into_iter().next().unwrap();
16708 let stone = self
16709 .state
16710 .social_chat
16711 .last_whisper_peer
16712 .as_ref()
16713 .is_some_and(|p| {
16714 p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone
16715 });
16716 Ok((id, label, stone))
16717 }
16718
16719 pub async fn trade_present_selected(
16720 &mut self,
16721 item_instance_id: uuid::Uuid,
16722 ) -> anyhow::Result<()> {
16723 self.trade_present_quantity(item_instance_id, None).await
16724 }
16725
16726 pub async fn trade_present_quantity(
16727 &mut self,
16728 item_instance_id: uuid::Uuid,
16729 quantity: Option<u32>,
16730 ) -> anyhow::Result<()> {
16731 self.seq += 1;
16732 self.session
16733 .submit_intent(Intent::TradePresent {
16734 entity_id: self.state.entity_id,
16735 item_instance_id,
16736 quantity,
16737 seq: self.seq,
16738 })
16739 .await?;
16740 self.state.intents_sent += 1;
16741 self.state.trade_ui.qty_entry = None;
16742 self.state.trade_ui.picking_inventory = false;
16743 Ok(())
16744 }
16745
16746 pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
16748 if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
16749 let qty = self.state.trade_ui.present_quantity();
16750 return self
16751 .trade_present_quantity(entry.item_instance_id, qty)
16752 .await;
16753 }
16754 if !self.state.trade_ui.picking_inventory {
16755 return Ok(());
16756 }
16757 let stacks = self.state.trade_presentable_stacks();
16758 let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
16759 return Ok(());
16760 };
16761 let Some(id) = stack.item_instance_id else {
16762 return Ok(());
16763 };
16764 let label = stack
16765 .display_name
16766 .clone()
16767 .unwrap_or_else(|| stack.template_id.clone());
16768 if stack.quantity <= 1 {
16769 self.trade_present_quantity(id, Some(1)).await
16770 } else {
16771 self.state
16772 .trade_ui
16773 .begin_qty_entry(id, label, stack.quantity);
16774 Ok(())
16775 }
16776 }
16777
16778 pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
16779 self.seq += 1;
16780 self.session
16781 .submit_intent(Intent::TradeSetReady {
16782 entity_id: self.state.entity_id,
16783 ready,
16784 seq: self.seq,
16785 })
16786 .await?;
16787 self.state.intents_sent += 1;
16788 Ok(())
16789 }
16790
16791 pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
16792 self.seq += 1;
16793 self.session
16794 .submit_intent(Intent::TradeCancel {
16795 entity_id: self.state.entity_id,
16796 seq: self.seq,
16797 })
16798 .await?;
16799 self.state.intents_sent += 1;
16800 self.state.trade_ui.close();
16801 Ok(())
16802 }
16803
16804 pub async fn destroy_whisper_stone(
16805 &mut self,
16806 item_instance_id: uuid::Uuid,
16807 ) -> anyhow::Result<()> {
16808 self.seq += 1;
16809 self.session
16810 .submit_intent(Intent::DestroyWhisperStone {
16811 entity_id: self.state.entity_id,
16812 item_instance_id,
16813 seq: self.seq,
16814 })
16815 .await?;
16816 self.state.intents_sent += 1;
16817 Ok(())
16818 }
16819
16820 pub async fn stop(&mut self) -> anyhow::Result<()> {
16821 self.seq += 1;
16822 self.session
16823 .submit_intent(Intent::Stop {
16824 entity_id: self.state.entity_id,
16825 seq: self.seq,
16826 })
16827 .await?;
16828 self.state.intents_sent += 1;
16829 Ok(())
16830 }
16831
16832 pub fn disconnect(&self) {
16833 self.session.disconnect();
16834 }
16835}
16836
16837fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
16838 let dx = ax - bx;
16839 let dy = ay - by;
16840 (dx * dx + dy * dy).sqrt()
16841}
16842
16843#[cfg(test)]
16844mod tests {
16845 use std::collections::BTreeMap;
16846
16847 use super::*;
16848 use flatland_protocol::{
16849 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
16850 };
16851
16852 fn sample_state() -> GameState {
16853 let mut state = GameState {
16854 session_id: 1,
16855 entity_id: 1,
16856 character_id: None,
16857 tick: 0,
16858 chunk_rev: 0,
16859 content_rev: 0,
16860 publish_rev: 0,
16861 entities: vec![EntityState {
16862 id: 1,
16863 label: "You".into(),
16864 transform: Transform {
16865 position: WorldCoord::surface(128.0, 128.0),
16866 yaw: 0.0,
16867 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
16868 },
16869 vitals: None,
16870 attributes: None,
16871 skills: None,
16872 inside_building: None,
16873 tile_id: None,
16874 paperdoll_ref: None,
16875 draw_scale: 1.0,
16876 presentation_state: None,
16877 sprite_mode: None,
16878 progression_xp: None,
16879 combat_cues: vec![],
16880 statuses: vec![],
16881 }],
16882 player: None,
16883 resource_nodes: vec![ResourceNodeView {
16884 id: "oak-1".into(),
16885 label: "Oak".into(),
16886 x: 130.0,
16887 y: 128.0,
16888 z: 0.0,
16889 item_template: "oak_log".into(),
16890 state: ResourceNodeState::Available,
16891 blocking: true,
16892 blocking_radius_m: 0.8,
16893 harvest_off: false,
16894 tile_id: None,
16895 yaw: 0.0,
16896 pitch: 0.0,
16897 roll: 0.0,
16898 draw_scale: 1.0,
16899 sprite_mode: None,
16900 growth_progress: None,
16901 presentation_state: None,
16902 channel_start_tick: None,
16903 channel_end_tick: None,
16904 harvest_drop_templates: vec![],
16905 }],
16906 harvest_route_nodes: vec![],
16907 ground_drops: vec![],
16908 placed_containers: vec![],
16909 buildings: vec![BuildingView {
16910 id: "broker-hut".into(),
16911 label: "Broker".into(),
16912 x: 148.0,
16913 y: 118.0,
16914 width_m: 8.0,
16915 depth_m: 6.0,
16916 interior_blueprint: Some("broker_hut".into()),
16917 tags: vec![],
16918 market_boundary_zone_ids: vec![],
16919 market_max_volume: None,
16920 wall_set: None,
16921 roof_set: None,
16922 }],
16923 doors: vec![flatland_protocol::DoorView {
16924 id: "door-1".into(),
16925 building_id: "broker-hut".into(),
16926 x: 148.0,
16927 y: 118.0,
16928 open: false,
16929 portal: Some("front".into()),
16930 locked: false,
16931 accessible: true,
16932 lock_id: None,
16933 }],
16934 interior_map: None,
16935 npcs: vec![],
16936 blueprints: vec![],
16937 building_materials: vec![],
16938 world_x0: 0.0,
16939 world_y0: 0.0,
16940 world_width_m: 256.0,
16941 world_height_m: 256.0,
16942 terrain_zones: Vec::new(),
16943 z_platforms: Vec::new(),
16944 z_transitions: Vec::new(),
16945 z_bands_outdoor_backup: None,
16946 world_clock: flatland_protocol::WorldClock::default(),
16947 inventory: std::collections::HashMap::new(),
16948 inventory_hints: std::collections::HashMap::new(),
16949 item_catalog: std::collections::HashMap::new(),
16950 logs: VecDeque::new(),
16951 intents_sent: 0,
16952 ticks_received: 0,
16953 connected: true,
16954 disconnect_reason: None,
16955 show_stats: false,
16956 hud_log_hidden: false,
16957 show_equip_menu: false,
16958 equip_menu_index: 0,
16959 show_craft_menu: false,
16960 show_plot_build_menu: false,
16961 plot_build_focus_wall: true,
16962 plot_build_wall_index: 0,
16963 plot_build_roof_index: 0,
16964 craft_menu_index: 0,
16965 craft_batch_quantity: 1,
16966 craft_tab: CraftTab::Ready,
16967 craft_filter: String::new(),
16968 craft_filter_focused: false,
16969 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
16970 show_shop_menu: false,
16971 shop_catalog: None,
16972 bank_panel: None,
16973 bank_menu_index: 0,
16974 bank_ui_mode: BankUiMode::Menu,
16975 storage_panel: None,
16976 market_panel: None,
16977 market_menu_index: 0,
16978 market_filter: String::new(),
16979 market_filter_focused: false,
16980 market_category_filter: None,
16981 market_buy_confirm: None,
16982 market_ui_mode: MarketUiMode::Browse,
16983 storage_menu_index: 0,
16984 storage_ui_mode: StorageUiMode::Menu,
16985 shop_tab: ShopTab::default(),
16986 shop_menu_index: 0,
16987 shop_quantity: 1,
16988 shop_trade_log: VecDeque::new(),
16989 show_npc_verb_menu: false,
16990 npc_verb_target: None,
16991 npc_verb_index: 0,
16992 npc_verb_notice: None,
16993 player_verbs: crate::social::PlayerVerbState::default(),
16994 social_chat: crate::social::SocialChatState::default(),
16995 trade_ui: crate::social::TradeUiState::default(),
16996 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
16997 show_npc_chat: false,
16998 npc_chat: None,
16999 show_inventory_menu: false,
17000 inventory_menu_index: 0,
17001 inventory_tab: InventoryTab::OnPerson,
17002 inventory_filter: String::new(),
17003 inventory_filter_focused: false,
17004 show_move_picker: false,
17005 show_rename_prompt: false,
17006 rename_plot_id: None,
17007 highlighted_plot_id: None,
17008 show_worker_rename: false,
17009 rename_buffer: String::new(),
17010 move_picker_index: 0,
17011 move_picker: None,
17012 show_grant_picker: false,
17013 grant_picker_index: 0,
17014 grant_picker: None,
17015 show_destroy_picker: false,
17016 destroy_confirm_pending: false,
17017 destroy_picker: None,
17018 combat_target: None,
17019 combat_target_label: None,
17020 ground_target: None,
17021 combat_fx: Vec::new(),
17022 ground_hazards: Vec::new(),
17023 property_zones: Vec::new(),
17024 tax_zones: Vec::new(),
17025 growth_zones: Vec::new(),
17026 biome_zones: Vec::new(),
17027 terrain_kind_nav: Vec::new(),
17028 property_plots: Vec::new(),
17029 property_plot_settings: None,
17030 claim_mode: None,
17031 relocate_mode: None,
17032 sell_plot_confirm: None,
17033 sell_plot_armed_at: None,
17034 show_plant_menu: false,
17035 plant_menu_index: 0,
17036 show_farm_access: false,
17037 farm_access_name_draft: String::new(),
17038 farm_access_discount_bps: 0,
17039 farm_access_index: 0,
17040 plant_quantity: 1,
17041 in_combat: false,
17042 auto_attack: true,
17043 combat_has_los: false,
17044 attack_cd_ticks: 0,
17045 gcd_ticks: 0,
17046 weapon_ability_id: "unarmed".into(),
17047 mainhand_template_id: None,
17048 mainhand_label: None,
17049 mainhand_instance_id: None,
17050 offhand_template_id: None,
17051 offhand_label: None,
17052 offhand_instance_id: None,
17053 mainhand_hand_slots: 1,
17054 defense: None,
17055 worn: BTreeMap::new(),
17056 carry_mass: 0.0,
17057 carry_mass_max: 0.0,
17058 encumbrance: flatland_protocol::EncumbranceState::Light,
17059 move_speed_mps: 0.0,
17060 move_speed_mult: 0.0,
17061 inventory_stacks: Vec::new(),
17062 keychain_stacks: Vec::new(),
17063 whisper_pouch_stacks: Vec::new(),
17064 combat_target_detail: None,
17065 statuses: Vec::new(),
17066 cast_progress: None,
17067 timed_channel: None,
17068 plot_build_offer: None,
17069 ability_cooldowns: Vec::new(),
17070 blocking_active: false,
17071 max_target_slots: 1,
17072 combat_slots: Vec::new(),
17073 rotation_presets: Vec::new(),
17074 known_abilities: Vec::new(),
17075 ability_meta: std::collections::HashMap::new(),
17076 ability_mastery: std::collections::HashMap::new(),
17077 hotbar: vec![None; 9],
17078 max_abilities_per_rotation: 0,
17079 show_loadout_menu: false,
17080 show_keychain_menu: false,
17081 keychain_menu_index: 0,
17082 show_rotation_editor: false,
17083 loadout_menu_index: 0,
17084 loadout_hotbar_slot: 1,
17085 loadout_ability_index: 0,
17086 loadout_focus_presets: false,
17087 rotation_editor: RotationEditorState::default(),
17088 harvest_in_progress: false,
17089 harvest_started_at: None,
17090 pending_craft_ack: None,
17091 craft_channel_blueprint_id: None,
17092 pending_worker_job_ack: None,
17093 attending_worker_instance_id: None,
17094 quest_log: Vec::new(),
17095 interactables: Vec::new(),
17096 ledger: None,
17097 career: None,
17098 character_sheet_tab: CharacterSheetTab::Character,
17099 ledger_period: LedgerPeriod::Day,
17100 show_quest_offer: false,
17101 pending_quest_offers: Vec::new(),
17102 quest_offer_index: 0,
17103 show_quest_menu: false,
17104 quest_menu_index: 0,
17105 quest_withdraw_confirm: false,
17106 hired_workers: Vec::new(),
17107 show_workers_menu: false,
17108 workers_menu_index: 0,
17109 worker_dismiss_confirmation: None,
17110 workers_menu_compact: false,
17111 worker_step_display: BTreeMap::new(),
17112 worker_error_display: BTreeMap::new(),
17113 worker_health_ring_until: BTreeMap::new(),
17114 pending_worker_hire_since: None,
17115 show_worker_give_picker: false,
17116 worker_give_picker_index: 0,
17117 worker_give_picker: None,
17118 show_worker_give_target_picker: false,
17119 worker_give_target_picker_index: 0,
17120 worker_give_target_picker: None,
17121 show_worker_take_picker: false,
17122 worker_take_picker_index: 0,
17123 worker_take_picker: None,
17124 show_worker_teach_picker: false,
17125 worker_teach_picker_index: 0,
17126 worker_teach_picker: None,
17127 worker_route_editor: None,
17128 progression_curve: None,
17129 };
17130 state.player = state.entities.first().cloned();
17131 state
17132 }
17133
17134 #[test]
17135 fn template_display_name_uses_item_catalog_for_uuid_ids() {
17136 let mut state = sample_state();
17137 let id = "7ac81ed5-e3d3-4b7f-a7fb-11d6c34a0995";
17138 assert_eq!(state.template_display_name(id), "Unknown item");
17139 state.item_catalog.insert(
17140 id.into(),
17141 ItemCatalogEntryView {
17142 template_id: id.into(),
17143 display_name: "Emerald".into(),
17144 category: "resource".into(),
17145 seed_for: None,
17146 },
17147 );
17148 assert_eq!(state.template_display_name(id), "Emerald");
17149 }
17150
17151 #[test]
17152 fn whisper_cancels_when_peer_walks_out_of_range() {
17153 let mut state = sample_state();
17154 state.player = state.entities.first().cloned();
17155 let mut peer = state.entities[0].clone();
17156 peer.id = 2;
17157 peer.label = "Ada".into();
17158 peer.transform.position = WorldCoord::surface(129.0, 128.0); state.entities.push(peer.clone());
17160 state.social_chat.focus_whisper(2, "Ada");
17161 state.refresh_whisper_range();
17162 assert!(matches!(
17163 state.social_chat.thread,
17164 crate::social::ChatThreadKind::Whisper { peer: 2 }
17165 ));
17166
17167 peer.transform.position = WorldCoord::surface(132.0, 128.0); state.entities[1] = peer;
17169 state.refresh_whisper_range();
17170 assert_eq!(
17171 state.social_chat.thread,
17172 crate::social::ChatThreadKind::Nearby
17173 );
17174 assert!(!state.social_chat.input_focused);
17175 }
17176
17177 #[test]
17178 fn probe_use_world_hired_worker_manage() {
17179 let mut state = sample_state();
17180 state
17181 .hired_workers
17182 .push(flatland_protocol::HiredWorkerView {
17183 instance_id: "worker-1".into(),
17184 entity_id: 42,
17185 def_id: "worker_laborer".into(),
17186 label: "Sam".into(),
17187 x: 129.0,
17188 y: 128.0,
17189 z: 0.0,
17190 mode: flatland_protocol::WorkerModeView::JobLoop,
17191 state: flatland_protocol::WorkerStateView::Working,
17192 step_label: "cultivate".into(),
17193 vitals: flatland_protocol::WorkerVitalsSummary {
17194 health_pct: 100.0,
17195 stamina_pct: 100.0,
17196 mana_pct: 100.0,
17197 hunger_pct: 100.0,
17198 thirst_pct: 100.0,
17199 },
17200 carry_pct: 0.0,
17201 last_error: None,
17202 wage_copper_per_interval: 1,
17203 effective_wage_copper: 1,
17204 wage_meters_walked: 0.0,
17205 lodging_container_id: None,
17206 route: None,
17207 route_stop_index: None,
17208 known_blueprint_ids: Vec::new(),
17209 level: 1,
17210 worker_xp: 0.0,
17211 inventory: Vec::new(),
17212 equipment: flatland_protocol::WorkerEquipmentView::default(),
17213 issue_hint: None,
17214 });
17215 let probe = state.probe_use_world();
17216 let primary = probe.primary.expect("primary");
17217 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17218 assert_eq!(primary.id, "worker-1");
17219 assert!(primary.hint_line().contains("Manage"));
17220 assert!(primary.hint_line().contains("Sam"));
17221 assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17222 }
17223
17224 fn sample_hired_worker(x: f32, y: f32) -> flatland_protocol::HiredWorkerView {
17225 flatland_protocol::HiredWorkerView {
17226 instance_id: "worker-1".into(),
17227 entity_id: 42,
17228 def_id: "worker_laborer".into(),
17229 label: "Sam".into(),
17230 x,
17231 y,
17232 z: 0.0,
17233 mode: flatland_protocol::WorkerModeView::JobLoop,
17234 state: flatland_protocol::WorkerStateView::Working,
17235 step_label: "follow".into(),
17236 vitals: flatland_protocol::WorkerVitalsSummary {
17237 health_pct: 100.0,
17238 stamina_pct: 100.0,
17239 mana_pct: 100.0,
17240 hunger_pct: 100.0,
17241 thirst_pct: 100.0,
17242 },
17243 carry_pct: 0.0,
17244 last_error: None,
17245 wage_copper_per_interval: 1,
17246 effective_wage_copper: 1,
17247 wage_meters_walked: 0.0,
17248 lodging_container_id: None,
17249 route: None,
17250 route_stop_index: None,
17251 known_blueprint_ids: Vec::new(),
17252 level: 1,
17253 worker_xp: 0.0,
17254 inventory: Vec::new(),
17255 equipment: flatland_protocol::WorkerEquipmentView::default(),
17256 issue_hint: None,
17257 }
17258 }
17259
17260 #[test]
17261 fn probe_harvest_beats_closer_hired_worker() {
17262 let mut state = sample_state();
17263 state.resource_nodes[0].x = 129.0;
17264 state.resource_nodes[0].y = 128.0;
17265 state.hired_workers.push(sample_hired_worker(128.2, 128.0));
17266 let probe = state.probe_use_world();
17267 let primary = probe.primary.expect("primary");
17268 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17269 assert_eq!(primary.id, "oak-1");
17270 assert!(state.harvestable_node_in_range());
17271 assert_eq!(
17272 state.nearest_interact_target().as_deref(),
17273 Some("worker-1"),
17274 "harvest is not Interact — worker remains the interact target"
17275 );
17276 }
17277
17278 #[test]
17279 fn probe_door_beats_closer_hired_worker() {
17280 let mut state = sample_state();
17281 state.doors[0].x = 129.2;
17282 state.doors[0].y = 128.0;
17283 state.hired_workers.push(sample_hired_worker(128.3, 128.0));
17284 let probe = state.probe_use_world();
17285 let primary = probe.primary.expect("primary");
17286 assert!(
17287 matches!(
17288 primary.kind,
17289 crate::UseWorldKind::EnterDoor
17290 | crate::UseWorldKind::OpenDoor
17291 | crate::UseWorldKind::CloseDoor
17292 | crate::UseWorldKind::ExitDoor
17293 ),
17294 "door should win over closer worker, got {:?}",
17295 primary.kind
17296 );
17297 assert_eq!(primary.id, "door-1");
17298 assert_eq!(state.nearest_interact_target().as_deref(), Some("door-1"));
17299 }
17300
17301 #[test]
17302 fn probe_indoor_exit_door_beats_lodging_chest_pickup() {
17303 let mut state = sample_state();
17306 state.entities[0].inside_building = Some("player_house".into());
17307 state.entities[0].transform.position = WorldCoord::surface(5.0, 2.0);
17308 state.player = state.entities.first().cloned();
17309 state.buildings = vec![BuildingView {
17310 id: "player_house".into(),
17311 label: "MadSin's house".into(),
17312 x: 100.0,
17313 y: 100.0,
17314 width_m: 10.0,
17315 depth_m: 8.0,
17316 interior_blueprint: Some("player_house".into()),
17317 tags: vec!["player_built".into()],
17318 market_boundary_zone_ids: vec![],
17319 market_max_volume: None,
17320 wall_set: None,
17321 roof_set: None,
17322 }];
17323 state.doors = vec![flatland_protocol::DoorView {
17324 id: "house_exit".into(),
17325 building_id: "player_house".into(),
17326 x: 5.0,
17327 y: 1.0,
17328 open: true,
17329 portal: Some("front".into()),
17330 locked: false,
17331 accessible: true,
17332 lock_id: None,
17333 }];
17334 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17335 id: "lodging_bed".into(),
17336 template_id: "camp_bed".into(),
17337 display_name: "Camp bed".into(),
17338 x: 5.5,
17339 y: 2.4,
17340 z: 0.0,
17341 locked: false,
17342 accessible: true,
17343 owner_character_id: None,
17344 contents: vec![],
17345 lock_id: None,
17346 capacity_volume: None,
17347 item_instance_id: Some(uuid::Uuid::from_u128(99)),
17348 tile_id: None,
17349 worker_lodging_capacity: Some(1),
17350 blocking: false,
17351 blocking_radius_m: 0.0,
17352 building_id: Some("player_house".into()),
17353 }];
17354 let mut worker = sample_hired_worker(40.0, 40.0);
17356 worker.lodging_container_id = Some("lodging_bed".into());
17357 state.hired_workers.push(worker);
17358
17359 let probe = state.probe_use_world();
17360 let primary = probe.primary.expect("primary");
17361 assert!(
17362 matches!(
17363 primary.kind,
17364 crate::UseWorldKind::ExitDoor
17365 | crate::UseWorldKind::OpenDoor
17366 | crate::UseWorldKind::CloseDoor
17367 | crate::UseWorldKind::EnterDoor
17368 ),
17369 "indoor exit must beat lodging ChestPickup, got {:?}",
17370 primary.kind
17371 );
17372 assert_eq!(primary.id, "house_exit");
17373 assert_eq!(primary.kind.cascade_stage(), 0);
17374 assert!(
17375 probe
17376 .candidates
17377 .iter()
17378 .any(|c| c.kind == crate::UseWorldKind::ChestPickup && c.in_range),
17379 "lodging bed should still be an in-range chest candidate"
17380 );
17381 assert_eq!(
17382 state.nearest_interact_target().as_deref(),
17383 Some("house_exit"),
17384 "use_nearest interact path should target the door"
17385 );
17386 assert!(state.lodging_is_occupied("lodging_bed"));
17387 }
17388
17389 #[test]
17390 fn probe_worker_when_no_resource_or_door_in_range() {
17391 let mut state = sample_state();
17392 state.hired_workers.push(sample_hired_worker(129.0, 128.0));
17394 let probe = state.probe_use_world();
17395 let primary = probe.primary.expect("primary");
17396 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17397 assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17398 assert!(!state.harvestable_node_in_range());
17399 }
17400
17401 #[test]
17402 fn market_clerk_verb_options_include_market() {
17403 let mut state = sample_state();
17404 state.npcs.push(flatland_protocol::NpcView {
17405 id: "mira_market".into(),
17406 label: "Mira".into(),
17407 role: "market_clerk".into(),
17408 x: 129.0,
17409 y: 128.0,
17410 building_id: Some("town_market".into()),
17411 entity_id: None,
17412 life_state: None,
17413 hp_pct: None,
17414 can_trade: false,
17415 buy_templates: vec![],
17416 tile_id: None,
17417 behavior_state: None,
17418 presentation_state: None,
17419 sprite_mode: None,
17420 paperdoll_ref: None,
17421 draw_scale: 1.0,
17422 yaw: None,
17423 perception_fov_deg: None,
17424 perception_sight_m: None,
17425 perception_hear_m: None,
17426 quest_verbs: Vec::new(),
17427 });
17428 state.npc_verb_target = Some("mira_market".into());
17429 assert_eq!(
17430 state
17431 .npc_verb_options()
17432 .iter()
17433 .map(|v| v.label.as_str())
17434 .collect::<Vec<_>>(),
17435 vec!["Market", "Talk"]
17436 );
17437 }
17438
17439 #[test]
17440 fn butcher_verb_options_include_turn_in_for_give_item() {
17441 let mut state = sample_state();
17442 state.npcs.push(flatland_protocol::NpcView {
17443 id: "town_butcher_1".into(),
17444 label: "Brutus".into(),
17445 role: "butcher".into(),
17446 x: 129.0,
17447 y: 128.0,
17448 building_id: None,
17449 entity_id: None,
17450 life_state: None,
17451 hp_pct: None,
17452 can_trade: true,
17453 buy_templates: vec!["raw_venison".into()],
17454 tile_id: None,
17455 behavior_state: None,
17456 presentation_state: None,
17457 sprite_mode: None,
17458 paperdoll_ref: None,
17459 draw_scale: 1.0,
17460 yaw: None,
17461 perception_fov_deg: None,
17462 perception_sight_m: None,
17463 perception_hear_m: None,
17464 quest_verbs: Vec::new(),
17465 });
17466 state.quest_log.push(flatland_protocol::QuestLogEntry {
17467 quest_id: "deer_threat".into(),
17468 title: "Deer threat".into(),
17469 description: String::new(),
17470 status: flatland_protocol::QuestStatusView::Active,
17471 current_step_id: Some("deliver".into()),
17472 current_step_title: "Deliver venison".into(),
17473 current_step_index: 0,
17474 objectives: vec![flatland_protocol::QuestObjectiveProgress {
17475 label: "Give 3 Raw venison to Brutus".into(),
17476 current: 0,
17477 required: 3,
17478 done: false,
17479 kind: "give_item".into(),
17480 npc_ref: Some("town_butcher_1".into()),
17481 item_template: Some("raw_venison".into()),
17482 blueprint_id: None,
17483 building_id: None,
17484 }],
17485 current_step_reward: flatland_protocol::QuestRewardView::default(),
17486 completion_reward: flatland_protocol::QuestRewardView::default(),
17487 steps: Vec::new(),
17488 is_tracked: true,
17489 can_withdraw: true,
17490 });
17491 state.npc_verb_target = Some("town_butcher_1".into());
17492 assert_eq!(
17493 state
17494 .npc_verb_options()
17495 .iter()
17496 .map(|v| v.label.as_str())
17497 .collect::<Vec<_>>(),
17498 vec!["Turn in: Deer threat", "Talk", "Trade"]
17499 );
17500 }
17501
17502 #[test]
17503 fn ada_verb_options_include_quest_offer() {
17504 let mut state = sample_state();
17505 state.npcs.push(flatland_protocol::NpcView {
17506 id: "ada_broker".into(),
17507 label: "Ada".into(),
17508 role: "broker".into(),
17509 x: 129.0,
17510 y: 128.0,
17511 building_id: None,
17512 entity_id: None,
17513 life_state: None,
17514 hp_pct: None,
17515 can_trade: true,
17516 buy_templates: vec![],
17517 tile_id: None,
17518 behavior_state: None,
17519 presentation_state: None,
17520 sprite_mode: None,
17521 paperdoll_ref: Some("ada_broker".into()),
17522 draw_scale: 1.0,
17523 yaw: None,
17524 perception_fov_deg: None,
17525 perception_sight_m: None,
17526 perception_hear_m: None,
17527 quest_verbs: vec![flatland_protocol::NpcQuestVerb {
17528 quest_id: "ada_goblin_hunt".into(),
17529 label: "Ask about goblins".into(),
17530 kind: flatland_protocol::NpcQuestVerb::KIND_OFFER.into(),
17531 }],
17532 });
17533 state.npc_verb_target = Some("ada_broker".into());
17534 assert_eq!(
17535 state
17536 .npc_verb_options()
17537 .iter()
17538 .map(|v| v.label.as_str())
17539 .collect::<Vec<_>>(),
17540 vec!["Ask about goblins", "Talk", "Trade"]
17541 );
17542 }
17543
17544 #[test]
17545 fn market_list_excludes_currency_stacks() {
17546 let mut state = sample_state();
17547 state.inventory_stacks = vec![
17548 flatland_protocol::ItemStack {
17549 template_id: "copper_coin".into(),
17550 quantity: 50,
17551 item_instance_id: Some(uuid::Uuid::from_u128(10)),
17552 display_name: Some("Copper Coin".into()),
17553 ..Default::default()
17554 },
17555 flatland_protocol::ItemStack {
17556 template_id: "oak_log".into(),
17557 quantity: 2,
17558 item_instance_id: Some(uuid::Uuid::from_u128(11)),
17559 display_name: Some("Oak Log".into()),
17560 ..Default::default()
17561 },
17562 flatland_protocol::ItemStack {
17563 template_id: "whisper_stone".into(),
17564 quantity: 1,
17565 item_instance_id: Some(uuid::Uuid::from_u128(12)),
17566 display_name: Some("Whisper Stone".into()),
17567 category: Some("quest".into()),
17568 listable: Some(false),
17569 ..Default::default()
17570 },
17571 ];
17572 let opts = state.market_list_item_options(&MarketListSourceKind::Person);
17573 assert_eq!(opts.len(), 1);
17574 assert!(opts[0].label.contains("Oak"));
17575 }
17576
17577 #[test]
17578 fn market_browse_filters_by_category_and_search() {
17579 let mut state = sample_state();
17580 state.market_panel = Some(flatland_protocol::MarketPanel {
17581 npc_id: "mira_market".into(),
17582 npc_label: "Mira".into(),
17583 building_id: "town_market".into(),
17584 building_label: "Town Market".into(),
17585 used_volume: 0.0,
17586 max_volume: 100.0,
17587 listings: vec![
17588 flatland_protocol::MarketListingView {
17589 listing_id: uuid::Uuid::from_u128(1),
17590 seller_character_id: uuid::Uuid::from_u128(2),
17591 seller_label: "Ada".into(),
17592 hall_building_id: "town_market".into(),
17593 hall_label: "Town Market".into(),
17594 template_id: "oak_log".into(),
17595 display_name: "Oak Log".into(),
17596 category: "resource".into(),
17597 quantity: 3,
17598 unit_price_copper: 10,
17599 line_total_copper: 30,
17600 npc_price: false,
17601 npc_dump_unit_copper: None,
17602 mine: false,
17603 },
17604 flatland_protocol::MarketListingView {
17605 listing_id: uuid::Uuid::from_u128(3),
17606 seller_character_id: uuid::Uuid::from_u128(2),
17607 seller_label: "Ada".into(),
17608 hall_building_id: "town_market".into(),
17609 hall_label: "Town Market".into(),
17610 template_id: "short_sword".into(),
17611 display_name: "Short Sword".into(),
17612 category: "weapon".into(),
17613 quantity: 1,
17614 unit_price_copper: 100,
17615 line_total_copper: 100,
17616 npc_price: false,
17617 npc_dump_unit_copper: None,
17618 mine: false,
17619 },
17620 ],
17621 tax_bps: 0,
17622 tax_flat_copper: 0,
17623 list_vaults: vec![],
17624 });
17625 assert_eq!(state.market_filtered_listing_indices().len(), 2);
17626 state.market_category_filter = Some("Weapons");
17627 let weapons = state.market_filtered_listing_indices();
17628 assert_eq!(weapons.len(), 1);
17629 assert_eq!(
17630 state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
17631 "Short Sword"
17632 );
17633 state.market_category_filter = None;
17634 state.market_filter = "oak".into();
17635 let oak = state.market_filtered_listing_indices();
17636 assert_eq!(oak.len(), 1);
17637 assert_eq!(
17638 state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
17639 "Oak Log"
17640 );
17641 }
17642
17643 #[test]
17644 fn market_list_source_includes_person_and_vaults() {
17645 let mut state = sample_state();
17646 let item_id = uuid::Uuid::from_u128(1);
17647 state.inventory_stacks = vec![flatland_protocol::ItemStack {
17648 template_id: "oak_log".into(),
17649 quantity: 2,
17650 item_instance_id: Some(item_id),
17651 display_name: Some("Oak Log".into()),
17652 ..Default::default()
17653 }];
17654 state.market_panel = Some(flatland_protocol::MarketPanel {
17655 npc_id: "mira_market".into(),
17656 npc_label: "Mira".into(),
17657 building_id: "town_market".into(),
17658 building_label: "Town Market".into(),
17659 used_volume: 0.0,
17660 max_volume: 100.0,
17661 listings: vec![],
17662 tax_bps: 0,
17663 tax_flat_copper: 0,
17664 list_vaults: vec![flatland_protocol::MarketListVault {
17665 building_id: "town_storage".into(),
17666 building_label: "Town Storage".into(),
17667 contents: vec![flatland_protocol::ItemStack {
17668 template_id: "lumber".into(),
17669 quantity: 1,
17670 item_instance_id: Some(uuid::Uuid::from_u128(2)),
17671 display_name: Some("Lumber".into()),
17672 ..Default::default()
17673 }],
17674 }],
17675 });
17676 let sources = state.market_list_source_options();
17677 assert_eq!(sources.len(), 2);
17678 assert!(matches!(sources[0].0, MarketListSourceKind::Person));
17679 assert!(matches!(
17680 sources[1].0,
17681 MarketListSourceKind::TownStorage { .. }
17682 ));
17683 assert!(sources[1].1.contains("Town Storage"));
17684 }
17685
17686 #[test]
17687 fn npc_market_dump_estimate_from_town_storage_vault() {
17688 let mut state = sample_state();
17689 state.market_panel = Some(flatland_protocol::MarketPanel {
17690 npc_id: "mira_market".into(),
17691 npc_label: "Mira".into(),
17692 building_id: "town_market".into(),
17693 building_label: "Town Market".into(),
17694 used_volume: 0.0,
17695 max_volume: 100.0,
17696 listings: vec![],
17697 tax_bps: 0,
17698 tax_flat_copper: 0,
17699 list_vaults: vec![flatland_protocol::MarketListVault {
17700 building_id: "town_storage".into(),
17701 building_label: "Town Storage".into(),
17702 contents: vec![flatland_protocol::ItemStack {
17703 template_id: "lumber".into(),
17704 quantity: 3,
17705 item_instance_id: Some(uuid::Uuid::from_u128(2)),
17706 display_name: Some("Lumber".into()),
17707 base_value_copper: Some(20),
17708 ..Default::default()
17709 }],
17710 }],
17711 });
17712 assert_eq!(
17713 state.npc_market_dump_unit_estimate("lumber"),
17714 Some(9),
17715 "vault stack base_value should enable NPC price estimate"
17716 );
17717 }
17718
17719 #[test]
17720 fn probe_use_world_npc_beats_nearby_loot() {
17721 let mut state = sample_state();
17722 state.npcs.push(flatland_protocol::NpcView {
17723 id: "ada".into(),
17724 label: "Ada".into(),
17725 role: "broker".into(),
17726 x: 129.0,
17727 y: 128.0,
17728 building_id: None,
17729 entity_id: None,
17730 life_state: None,
17731 hp_pct: None,
17732 can_trade: true,
17733 buy_templates: vec!["lumber".into()],
17734 tile_id: None,
17735 behavior_state: None,
17736 presentation_state: None,
17737 sprite_mode: None,
17738 paperdoll_ref: None,
17739 draw_scale: 1.0,
17740 yaw: None,
17741 perception_fov_deg: None,
17742 perception_sight_m: None,
17743 perception_hear_m: None,
17744 quest_verbs: Vec::new(),
17745 });
17746 state.ground_drops.push(flatland_protocol::GroundDropView {
17747 id: "d1".into(),
17748 template_id: "lumber".into(),
17749 quantity: 1,
17750 x: 128.5,
17751 y: 128.0,
17752 z: 0.0,
17753 tile_id: None,
17754 display_name: None,
17755 yaw: 0.0,
17756 pitch: 0.0,
17757 roll: 0.0,
17758 draw_scale: 1.0,
17759 item_instance_id: None,
17760 props: Default::default(),
17761 status_bindings: Vec::new(),
17762 });
17763 let probe = state.probe_use_world();
17764 let primary = probe.primary.expect("primary");
17765 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
17766 assert_eq!(primary.id, "ada");
17767 }
17768
17769 #[test]
17770 fn probe_use_world_harvest_when_in_range() {
17771 let state = sample_state(); let probe = state.probe_use_world();
17773 assert!(
17774 probe.primary.is_none(),
17775 "oak is 2m away, out of harvest range"
17776 );
17777 assert!(probe
17778 .candidates
17779 .iter()
17780 .any(|c| c.kind == crate::UseWorldKind::Harvest));
17781
17782 let mut state = sample_state();
17783 state.resource_nodes[0].x = 129.0;
17784 let probe = state.probe_use_world();
17785 let primary = probe.primary.expect("primary");
17786 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17787 }
17788
17789 #[test]
17790 fn probe_use_world_door_uses_building_label() {
17791 let mut state = sample_state();
17792 state.doors[0].x = 129.0;
17793 state.doors[0].y = 128.0;
17794 let probe = state.probe_use_world();
17795 let primary = probe.primary.expect("primary");
17796 assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
17797 assert_eq!(primary.label, "Broker");
17798 assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
17799 }
17800
17801 #[test]
17802 fn empty_entity_tick_preserves_welcome_snapshot() {
17803 let mut state = sample_state();
17804 state.inventory.insert("carrot".into(), 3);
17805 let delta = TickDelta {
17806 tick: 1,
17807 entities: vec![],
17808 resource_nodes: vec![],
17809 ground_drops: vec![],
17810 placed_containers: vec![],
17811 buildings: vec![],
17812 doors: vec![],
17813 interior_map: None,
17814 npcs: vec![],
17815 inventory: vec![],
17816 blueprints: vec![],
17817 building_materials: vec![],
17818 world_clock: flatland_protocol::WorldClock::default(),
17819 combat: None,
17820 quest_log: vec![],
17821 hired_workers: Vec::new(),
17822 interactables: vec![],
17823 ledger: None,
17824 career: None,
17825 combat_fx: Vec::new(),
17826 ground_hazards: Vec::new(),
17827 property_plots: Vec::new(),
17828 terrain_overlays: Vec::new(),
17829 };
17830
17831 state.apply_tick_fields(&delta, 1);
17832
17833 assert_eq!(state.entities.len(), 1);
17834 assert!(state.player.is_some());
17835 assert_eq!(state.inventory.get("carrot"), Some(&3));
17836 assert_eq!(state.resource_nodes.len(), 1);
17837 }
17838
17839 #[test]
17840 fn tick_preserves_world_layers_when_delta_omits_them() {
17841 let mut state = sample_state();
17842 let delta = TickDelta {
17843 tick: 1,
17844 entities: state.entities.clone(),
17845 resource_nodes: vec![],
17846 ground_drops: vec![],
17847 placed_containers: vec![],
17848 buildings: vec![],
17849 doors: vec![],
17850 interior_map: None,
17851 npcs: vec![],
17852 inventory: vec![],
17853 blueprints: vec![],
17854 building_materials: vec![],
17855 world_clock: flatland_protocol::WorldClock::default(),
17856 combat: None,
17857 quest_log: vec![],
17858 hired_workers: Vec::new(),
17859 interactables: vec![],
17860 ledger: None,
17861 career: None,
17862 combat_fx: Vec::new(),
17863 ground_hazards: Vec::new(),
17864 property_plots: Vec::new(),
17865 terrain_overlays: Vec::new(),
17866 };
17867
17868 state.apply_tick_fields(&delta, 1);
17869
17870 assert_eq!(state.resource_nodes.len(), 1);
17871 assert_eq!(state.buildings.len(), 1);
17872 assert_eq!(state.doors.len(), 1);
17873 }
17874
17875 #[test]
17876 fn tick_updates_resource_nodes_when_server_sends_them() {
17877 let mut state = sample_state();
17878 let delta = TickDelta {
17879 tick: 1,
17880 entities: state.entities.clone(),
17881 resource_nodes: vec![ResourceNodeView {
17882 id: "oak-1".into(),
17883 label: "Oak".into(),
17884 x: 130.0,
17885 y: 128.0,
17886 z: 0.0,
17887 item_template: "oak_log".into(),
17888 state: ResourceNodeState::Cooldown,
17889 blocking: true,
17890 blocking_radius_m: 0.8,
17891 harvest_off: false,
17892 tile_id: None,
17893 yaw: 0.0,
17894 pitch: 0.0,
17895 roll: 0.0,
17896 draw_scale: 1.0,
17897 sprite_mode: None,
17898 growth_progress: None,
17899 presentation_state: None,
17900 channel_start_tick: None,
17901 channel_end_tick: None,
17902 harvest_drop_templates: vec![],
17903 }],
17904 buildings: vec![],
17905 doors: vec![],
17906 interior_map: None,
17907 npcs: vec![],
17908 inventory: vec![],
17909 blueprints: vec![],
17910 building_materials: vec![],
17911 world_clock: flatland_protocol::WorldClock::default(),
17912 ground_drops: vec![],
17913 placed_containers: vec![],
17914 combat: None,
17915 quest_log: vec![],
17916 hired_workers: Vec::new(),
17917 interactables: vec![],
17918 ledger: None,
17919 career: None,
17920 combat_fx: Vec::new(),
17921 ground_hazards: Vec::new(),
17922 property_plots: Vec::new(),
17923 terrain_overlays: Vec::new(),
17924 };
17925
17926 state.apply_tick_fields(&delta, 1);
17927
17928 assert!(matches!(
17929 state.resource_nodes[0].state,
17930 ResourceNodeState::Cooldown
17931 ));
17932 }
17933
17934 #[test]
17935 fn harvest_picker_keeps_welcome_nodes_after_aoi_tick() {
17936 let mut state = sample_state();
17937 let nearby = state.resource_nodes[0].clone();
17938 let mut far = nearby.clone();
17939 far.id = "far-oak".into();
17940 far.label = "Far Oak".into();
17941 far.x = 200.0;
17942 far.y = 200.0;
17943 state.replace_harvest_route_nodes(&[nearby.clone(), far.clone()]);
17944
17945 let delta = TickDelta {
17946 tick: 1,
17947 entities: state.entities.clone(),
17948 resource_nodes: vec![nearby],
17949 ground_drops: vec![],
17950 placed_containers: vec![],
17951 buildings: vec![],
17952 doors: vec![],
17953 interior_map: None,
17954 npcs: vec![],
17955 inventory: vec![],
17956 blueprints: vec![],
17957 building_materials: vec![],
17958 world_clock: flatland_protocol::WorldClock::default(),
17959 combat: None,
17960 quest_log: vec![],
17961 hired_workers: Vec::new(),
17962 interactables: vec![],
17963 ledger: None,
17964 career: None,
17965 combat_fx: Vec::new(),
17966 ground_hazards: Vec::new(),
17967 property_plots: Vec::new(),
17968 terrain_overlays: Vec::new(),
17969 };
17970 state.apply_tick_fields(&delta, 1);
17971
17972 assert_eq!(state.resource_nodes.len(), 1);
17973 let ids: Vec<_> = state
17974 .route_editor_node_candidates()
17975 .into_iter()
17976 .map(|n| n.id)
17977 .collect();
17978 assert!(ids.contains(&"oak-1".to_string()), "got {ids:?}");
17979 assert!(ids.contains(&"far-oak".to_string()), "got {ids:?}");
17980 }
17981
17982 #[test]
17983 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
17984 let mut state = GameState {
17985 session_id: 1,
17986 entity_id: 1,
17987 character_id: None,
17988 tick: 0,
17989 chunk_rev: 0,
17990 content_rev: 0,
17991 publish_rev: 0,
17992 entities: vec![EntityState {
17993 id: 1,
17994 label: "You".into(),
17995 transform: Transform {
17996 position: WorldCoord::surface(4.5, 2.0),
17997 yaw: 0.0,
17998 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
17999 },
18000 vitals: None,
18001 attributes: None,
18002 skills: None,
18003 inside_building: Some("broker_hut".into()),
18004 tile_id: None,
18005 paperdoll_ref: None,
18006 draw_scale: 1.0,
18007 presentation_state: None,
18008 sprite_mode: None,
18009 progression_xp: None,
18010 combat_cues: vec![],
18011 statuses: vec![],
18012 }],
18013 player: None,
18014 resource_nodes: vec![],
18015 harvest_route_nodes: vec![],
18016 ground_drops: vec![],
18017 placed_containers: vec![],
18018 buildings: vec![BuildingView {
18019 id: "broker_hut".into(),
18020 label: "Broker".into(),
18021 x: 158.0,
18022 y: 124.0,
18023 width_m: 8.0,
18024 depth_m: 6.0,
18025 interior_blueprint: Some("broker_hut".into()),
18026 tags: vec![],
18027 market_boundary_zone_ids: vec![],
18028 market_max_volume: None,
18029 wall_set: None,
18030 roof_set: None,
18031 }],
18032 doors: vec![flatland_protocol::DoorView {
18033 id: "broker_hut_exit".into(),
18034 building_id: "broker_hut".into(),
18035 x: 4.3,
18036 y: 0.9,
18037 open: true,
18038 portal: Some("front".into()),
18039 locked: false,
18040 accessible: true,
18041 lock_id: None,
18042 }],
18043 interior_map: None,
18044 npcs: vec![flatland_protocol::NpcView {
18045 id: "ada_broker".into(),
18046 label: "Ada".into(),
18047 x: 4.5,
18048 y: 2.0,
18049 building_id: Some("broker_hut".into()),
18050 role: "broker".into(),
18051 entity_id: None,
18052 life_state: None,
18053 hp_pct: None,
18054 can_trade: true,
18055 buy_templates: vec!["lumber".into()],
18056 tile_id: None,
18057 behavior_state: None,
18058 presentation_state: None,
18059 sprite_mode: None,
18060 paperdoll_ref: None,
18061 draw_scale: 1.0,
18062 yaw: None,
18063 perception_fov_deg: None,
18064 perception_sight_m: None,
18065 perception_hear_m: None,
18066 quest_verbs: Vec::new(),
18067 }],
18068 blueprints: vec![],
18069 building_materials: vec![],
18070 world_x0: 0.0,
18071 world_y0: 0.0,
18072 world_width_m: 256.0,
18073 world_height_m: 256.0,
18074 terrain_zones: Vec::new(),
18075 z_platforms: Vec::new(),
18076 z_transitions: Vec::new(),
18077 z_bands_outdoor_backup: None,
18078 world_clock: flatland_protocol::WorldClock::default(),
18079 inventory: std::collections::HashMap::new(),
18080 inventory_hints: std::collections::HashMap::new(),
18081 item_catalog: std::collections::HashMap::new(),
18082 logs: VecDeque::new(),
18083 intents_sent: 0,
18084 ticks_received: 0,
18085 connected: true,
18086 disconnect_reason: None,
18087 show_stats: false,
18088 hud_log_hidden: false,
18089 show_equip_menu: false,
18090 equip_menu_index: 0,
18091 show_craft_menu: false,
18092 show_plot_build_menu: false,
18093 plot_build_focus_wall: true,
18094 plot_build_wall_index: 0,
18095 plot_build_roof_index: 0,
18096 craft_menu_index: 0,
18097 craft_batch_quantity: 1,
18098 craft_tab: CraftTab::Ready,
18099 craft_filter: String::new(),
18100 craft_filter_focused: false,
18101 craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
18102 show_shop_menu: false,
18103 shop_catalog: None,
18104 bank_panel: None,
18105 bank_menu_index: 0,
18106 bank_ui_mode: BankUiMode::Menu,
18107 storage_panel: None,
18108 market_panel: None,
18109 market_menu_index: 0,
18110 market_filter: String::new(),
18111 market_filter_focused: false,
18112 market_category_filter: None,
18113 market_buy_confirm: None,
18114 market_ui_mode: MarketUiMode::Browse,
18115 storage_menu_index: 0,
18116 storage_ui_mode: StorageUiMode::Menu,
18117 shop_tab: ShopTab::default(),
18118 shop_menu_index: 0,
18119 shop_quantity: 1,
18120 shop_trade_log: VecDeque::new(),
18121 show_npc_verb_menu: false,
18122 npc_verb_target: None,
18123 npc_verb_index: 0,
18124 npc_verb_notice: None,
18125 player_verbs: crate::social::PlayerVerbState::default(),
18126 social_chat: crate::social::SocialChatState::default(),
18127 trade_ui: crate::social::TradeUiState::default(),
18128 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
18129 show_npc_chat: false,
18130 npc_chat: None,
18131 show_inventory_menu: false,
18132 inventory_menu_index: 0,
18133 inventory_tab: InventoryTab::OnPerson,
18134 inventory_filter: String::new(),
18135 inventory_filter_focused: false,
18136 show_move_picker: false,
18137 show_rename_prompt: false,
18138 rename_plot_id: None,
18139 highlighted_plot_id: None,
18140 show_worker_rename: false,
18141 rename_buffer: String::new(),
18142 move_picker_index: 0,
18143 move_picker: None,
18144 show_grant_picker: false,
18145 grant_picker_index: 0,
18146 grant_picker: None,
18147 show_destroy_picker: false,
18148 destroy_confirm_pending: false,
18149 destroy_picker: None,
18150 combat_target: None,
18151 combat_target_label: None,
18152 ground_target: None,
18153 combat_fx: Vec::new(),
18154 ground_hazards: Vec::new(),
18155 property_zones: Vec::new(),
18156 tax_zones: Vec::new(),
18157 growth_zones: Vec::new(),
18158 biome_zones: Vec::new(),
18159 terrain_kind_nav: Vec::new(),
18160 property_plots: Vec::new(),
18161 property_plot_settings: None,
18162 claim_mode: None,
18163 relocate_mode: None,
18164 sell_plot_confirm: None,
18165 sell_plot_armed_at: None,
18166 show_plant_menu: false,
18167 plant_menu_index: 0,
18168 show_farm_access: false,
18169 farm_access_name_draft: String::new(),
18170 farm_access_discount_bps: 0,
18171 farm_access_index: 0,
18172 plant_quantity: 1,
18173 in_combat: false,
18174 auto_attack: true,
18175 combat_has_los: false,
18176 attack_cd_ticks: 0,
18177 gcd_ticks: 0,
18178 weapon_ability_id: "unarmed".into(),
18179 mainhand_template_id: None,
18180 mainhand_label: None,
18181 mainhand_instance_id: None,
18182 offhand_template_id: None,
18183 offhand_label: None,
18184 offhand_instance_id: None,
18185 mainhand_hand_slots: 1,
18186 defense: None,
18187 worn: BTreeMap::new(),
18188 carry_mass: 0.0,
18189 carry_mass_max: 0.0,
18190 encumbrance: flatland_protocol::EncumbranceState::Light,
18191 move_speed_mps: 0.0,
18192 move_speed_mult: 0.0,
18193 inventory_stacks: Vec::new(),
18194 keychain_stacks: Vec::new(),
18195 whisper_pouch_stacks: Vec::new(),
18196 combat_target_detail: None,
18197 statuses: Vec::new(),
18198 cast_progress: None,
18199 timed_channel: None,
18200 plot_build_offer: None,
18201 ability_cooldowns: Vec::new(),
18202 blocking_active: false,
18203 max_target_slots: 1,
18204 combat_slots: Vec::new(),
18205 rotation_presets: Vec::new(),
18206 known_abilities: Vec::new(),
18207 ability_meta: std::collections::HashMap::new(),
18208 ability_mastery: std::collections::HashMap::new(),
18209 hotbar: vec![None; 9],
18210 max_abilities_per_rotation: 0,
18211 show_loadout_menu: false,
18212 show_keychain_menu: false,
18213 keychain_menu_index: 0,
18214 show_rotation_editor: false,
18215 loadout_menu_index: 0,
18216 loadout_hotbar_slot: 1,
18217 loadout_ability_index: 0,
18218 loadout_focus_presets: false,
18219 rotation_editor: RotationEditorState::default(),
18220 harvest_in_progress: false,
18221 harvest_started_at: None,
18222 pending_craft_ack: None,
18223 craft_channel_blueprint_id: None,
18224 pending_worker_job_ack: None,
18225 attending_worker_instance_id: None,
18226 quest_log: Vec::new(),
18227 interactables: Vec::new(),
18228 ledger: None,
18229 career: None,
18230 character_sheet_tab: CharacterSheetTab::Character,
18231 ledger_period: LedgerPeriod::Day,
18232 show_quest_offer: false,
18233 pending_quest_offers: Vec::new(),
18234 quest_offer_index: 0,
18235 show_quest_menu: false,
18236 quest_menu_index: 0,
18237 quest_withdraw_confirm: false,
18238 hired_workers: Vec::new(),
18239 show_workers_menu: false,
18240 workers_menu_index: 0,
18241 worker_dismiss_confirmation: None,
18242 workers_menu_compact: false,
18243 worker_step_display: BTreeMap::new(),
18244 worker_error_display: BTreeMap::new(),
18245 worker_health_ring_until: BTreeMap::new(),
18246 pending_worker_hire_since: None,
18247 show_worker_give_picker: false,
18248 worker_give_picker_index: 0,
18249 worker_give_picker: None,
18250 show_worker_give_target_picker: false,
18251 worker_give_target_picker_index: 0,
18252 worker_give_target_picker: None,
18253 show_worker_take_picker: false,
18254 worker_take_picker_index: 0,
18255 worker_take_picker: None,
18256 show_worker_teach_picker: false,
18257 worker_teach_picker_index: 0,
18258 worker_teach_picker: None,
18259 worker_route_editor: None,
18260 progression_curve: None,
18261 };
18262 state.player = state.entities.first().cloned();
18263 assert_eq!(
18264 state.nearest_interact_target().as_deref(),
18265 Some("ada_broker")
18266 );
18267 }
18268
18269 #[test]
18270 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
18271 let mut state = sample_state();
18272 state.placed_containers = vec![
18275 flatland_protocol::PlacedContainerView {
18276 id: "near".into(),
18277 template_id: "wooden_chest_small".into(),
18278 display_name: "Wooden Chest".into(),
18279 x: 130.0,
18280 y: 128.0,
18281 z: 0.0,
18282 locked: true,
18283 accessible: true,
18284 owner_character_id: None,
18285 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
18286 lock_id: None,
18287 capacity_volume: None,
18288 item_instance_id: Some(uuid::Uuid::from_u128(1)),
18289 tile_id: None,
18290 worker_lodging_capacity: None,
18291 blocking: false,
18292 blocking_radius_m: 0.0,
18293 building_id: None,
18294 },
18295 flatland_protocol::PlacedContainerView {
18296 id: "far".into(),
18297 template_id: "wooden_chest_small".into(),
18298 display_name: "Distant Chest".into(),
18299 x: 128.0 + CONTAINER_RANGE_M + 5.0,
18300 y: 128.0,
18301 z: 0.0,
18302 locked: false,
18303 accessible: true,
18304 owner_character_id: None,
18305 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
18306 lock_id: None,
18307 capacity_volume: None,
18308 item_instance_id: Some(uuid::Uuid::from_u128(2)),
18309 tile_id: None,
18310 worker_lodging_capacity: None,
18311 blocking: false,
18312 blocking_radius_m: 0.0,
18313 building_id: None,
18314 },
18315 ];
18316
18317 let nearby = state.nearby_containers();
18318 assert_eq!(
18319 nearby.len(),
18320 1,
18321 "far chest must not appear once out of range"
18322 );
18323 assert_eq!(nearby[0].view.id, "near");
18324 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
18325 assert!(nearby[0].rows[0].is_chest_shell);
18326
18327 state.placed_containers[0].accessible = false;
18330 let nearby = state.nearby_containers();
18331 assert_eq!(nearby.len(), 1);
18332 assert_eq!(nearby[0].rows.len(), 1);
18333 assert!(nearby[0].rows[0].is_chest_shell);
18334 }
18335
18336 #[test]
18337 fn chest_pickup_destinations_offer_person_and_worn_bag() {
18338 let mut state = sample_state();
18339 let back_id = uuid::Uuid::from_u128(42);
18340 state.worn.insert(
18341 BodySlot::Back,
18342 flatland_protocol::ItemStack {
18343 template_id: "travel_backpack".into(),
18344 quantity: 1,
18345 item_instance_id: Some(back_id),
18346 props: Default::default(),
18347 status_bindings: Vec::new(),
18348 contents: Vec::new(),
18349 display_name: Some("Travel Backpack".into()),
18350 category: Some("container".into()),
18351 base_mass: Some(2.5),
18352 base_volume: Some(12.0),
18353 capacity_volume: Some(80.0),
18354 stackable: Some(false),
18355 world_placeable: Some(false),
18356 worker_lodging_capacity: None,
18357 equip_slot: None,
18358 armor_physical: None,
18359 resists: vec![],
18360 hand_slots: None,
18361 listable: None,
18362 ..Default::default()
18363 },
18364 );
18365 let opts = state.chest_pickup_destinations("chest-1");
18366 assert!(matches!(
18367 opts.first().map(|o| &o.kind),
18368 Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
18369 ));
18370 assert!(opts.iter().any(|o| matches!(
18371 &o.kind,
18372 MoveOptionKind::PickupPlaced {
18373 nest_parent_instance_id: None,
18374 ..
18375 }
18376 )));
18377 assert!(opts.iter().any(|o| matches!(
18378 &o.kind,
18379 MoveOptionKind::PickupPlaced {
18380 nest_parent_instance_id: Some(id),
18381 ..
18382 } if *id == back_id
18383 )));
18384 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18385 }
18386
18387 #[test]
18388 fn placed_container_public_label_hides_owner_custom_name() {
18389 let owner = uuid::Uuid::from_u128(99);
18390 let mut state = sample_state();
18391 state.character_id = Some(uuid::Uuid::from_u128(1));
18392 state.inventory_hints.insert(
18393 "wooden_chest_medium".into(),
18394 InventoryHint {
18395 display_name: "Medium Wooden Chest".into(),
18396 category: "container".into(),
18397 base_mass: None,
18398 base_volume: None,
18399 capacity_volume: None,
18400 stackable: false,
18401 listable: true,
18402 base_value_copper: None,
18403 },
18404 );
18405 let chest = flatland_protocol::PlacedContainerView {
18406 id: "c1".into(),
18407 template_id: "wooden_chest_medium".into(),
18408 display_name: "Barry's Loot #a3f2".into(),
18409 x: 128.0,
18410 y: 128.0,
18411 z: 0.0,
18412 locked: false,
18413 accessible: true,
18414 owner_character_id: Some(owner),
18415 contents: vec![],
18416 lock_id: None,
18417 capacity_volume: None,
18418 item_instance_id: None,
18419 tile_id: None,
18420 worker_lodging_capacity: None,
18421 blocking: false,
18422 blocking_radius_m: 0.0,
18423 building_id: None,
18424 };
18425 assert_eq!(
18426 state.placed_container_public_label(&chest),
18427 "Medium Wooden Chest"
18428 );
18429 state.character_id = Some(owner);
18430 assert_eq!(
18431 state.placed_container_public_label(&chest),
18432 "Barry's Loot #a3f2"
18433 );
18434 }
18435
18436 #[test]
18437 fn location_context_shows_crop_growth_percent_not_depleted() {
18438 let mut state = sample_state();
18439 state.player = state.entities.first().cloned();
18440 state.resource_nodes[0].label = "Carrot (growing)".into();
18441 state.resource_nodes[0].x = 128.2;
18442 state.resource_nodes[0].y = 128.0;
18443 state.resource_nodes[0].state = ResourceNodeState::Cooldown;
18444 state.resource_nodes[0].growth_progress = Some(0.47);
18445 let lines = state.location_context_lines();
18446 let line = lines
18447 .iter()
18448 .find(|l| l.text.contains("Carrot"))
18449 .map(|l| l.text.as_str())
18450 .unwrap_or("");
18451 assert!(
18452 line.contains("(growing, 47%)"),
18453 "expected growth percent, got: {line}"
18454 );
18455 assert!(
18456 !line.contains("depleted"),
18457 "growing crop should not show depleted: {line}"
18458 );
18459 }
18460
18461 #[test]
18462 fn resource_node_near_action_suffix_prefers_growth() {
18463 let node = ResourceNodeView {
18464 id: "crop".into(),
18465 label: "Wheat".into(),
18466 x: 0.0,
18467 y: 0.0,
18468 z: 0.0,
18469 item_template: "wheat".into(),
18470 state: ResourceNodeState::Cooldown,
18471 blocking: false,
18472 blocking_radius_m: 0.0,
18473 harvest_off: false,
18474 tile_id: None,
18475 yaw: 0.0,
18476 pitch: 0.0,
18477 roll: 0.0,
18478 draw_scale: 1.0,
18479 sprite_mode: None,
18480 growth_progress: Some(0.12),
18481 presentation_state: None,
18482 channel_start_tick: None,
18483 channel_end_tick: None,
18484 harvest_drop_templates: vec![],
18485 };
18486 assert_eq!(resource_node_near_action_suffix(&node), " (growing, 12%)");
18487 }
18488
18489 #[test]
18490 fn location_context_lists_nearby_resource_node() {
18491 let mut state = sample_state();
18492 state.player = state.entities.first().cloned();
18493 state.resource_nodes[0].x = 128.2;
18494 state.resource_nodes[0].y = 128.0;
18495 let lines = state.location_context_lines();
18496 assert!(
18497 lines
18498 .iter()
18499 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
18500 "expected resource node in context: {:?}",
18501 lines
18502 );
18503 }
18504
18505 #[test]
18506 fn quest_board_usable_within_board_radius() {
18507 let mut state = sample_state();
18508 state.player = state.entities.first().cloned();
18509 state.interactables = vec![flatland_protocol::InteractableView {
18510 id: "board-1".into(),
18511 kind: "quest_board".into(),
18512 label: "Town Quest Board".into(),
18513 x: 130.5,
18514 y: 128.0,
18515 z: 0.0,
18516 board_id: Some("starter_town_board".into()),
18517 }];
18518 assert_eq!(
18520 state.nearest_interact_target().as_deref(),
18521 Some("board-1"),
18522 "quest board should be selectable at ~2.5m"
18523 );
18524 let lines = state.location_context_lines();
18525 assert!(
18526 lines
18527 .iter()
18528 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
18529 "HUD should advertise f when board is in range: {:?}",
18530 lines
18531 );
18532 }
18533
18534 #[test]
18535 fn quest_board_keeps_multiple_offers() {
18536 let mut state = sample_state();
18537 let offer = |id: &str, title: &str| flatland_protocol::QuestOffer {
18538 quest_id: id.into(),
18539 title: title.into(),
18540 description: format!("{title} desc"),
18541 step_count: 2,
18542 };
18543 state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18544 state.push_quest_offer(offer("daily_20695_1", "Town board: Deer threat"));
18545 state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18546 assert_eq!(state.pending_quest_offers.len(), 2);
18547 assert_eq!(
18548 state.selected_quest_offer().unwrap().quest_id,
18549 "ada_goblin_hunt"
18550 );
18551 state.move_quest_offer_selection(1);
18552 assert_eq!(
18553 state.selected_quest_offer().unwrap().quest_id,
18554 "daily_20695_1"
18555 );
18556 state.remove_quest_offer("daily_20695_1");
18557 assert_eq!(state.pending_quest_offers.len(), 1);
18558 assert!(state.show_quest_offer);
18559 state.remove_quest_offer("ada_goblin_hunt");
18560 assert!(!state.show_quest_offer);
18561 assert!(state.pending_quest_offers.is_empty());
18562 }
18563
18564 #[test]
18565 fn inventory_selectable_rows_excludes_equipped_shells_but_keeps_bag_contents() {
18566 let mut state = sample_state();
18567 state.worn.insert(
18568 BodySlot::Back,
18569 flatland_protocol::ItemStack {
18570 template_id: "travel_backpack".into(),
18571 quantity: 1,
18572 item_instance_id: Some(uuid::Uuid::from_u128(3)),
18573 props: Default::default(),
18574 status_bindings: Vec::new(),
18575 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
18576 display_name: None,
18577 category: None,
18578 base_mass: None,
18579 base_volume: None,
18580 capacity_volume: None,
18581 stackable: None,
18582 world_placeable: None,
18583 worker_lodging_capacity: None,
18584 equip_slot: None,
18585 armor_physical: None,
18586 resists: vec![],
18587 hand_slots: None,
18588 listable: None,
18589 ..Default::default()
18590 },
18591 );
18592 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
18593 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18594 id: "chest-1".into(),
18595 template_id: "wooden_chest_small".into(),
18596 display_name: "Wooden Chest".into(),
18597 x: 129.0,
18598 y: 128.0,
18599 z: 0.0,
18600 locked: false,
18601 accessible: true,
18602 owner_character_id: None,
18603 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
18604 lock_id: None,
18605 capacity_volume: None,
18606 item_instance_id: Some(uuid::Uuid::from_u128(4)),
18607 tile_id: None,
18608 worker_lodging_capacity: None,
18609 blocking: false,
18610 blocking_radius_m: 0.0,
18611 building_id: None,
18612 }];
18613
18614 state.inventory_tab = InventoryTab::OnPerson;
18615 let rows = state.inventory_selectable_rows();
18616 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
18617 assert_eq!(
18618 sections,
18619 vec![
18620 InventorySection::Person, InventorySection::Person, ]
18623 );
18624 assert_eq!(rows[0].stack.template_id, "iron_ore");
18625 assert_eq!(rows[0].depth, 0);
18626 assert!(!rows[0].is_equip_shell);
18627 assert_eq!(rows[1].stack.template_id, "lumber");
18628
18629 let lines = state.inventory_browser_lines();
18630 assert!(lines.iter().any(|l| matches!(
18631 l,
18632 InventoryBrowserLine::Section(s) if s.contains("carried bags")
18633 )));
18634 assert!(lines.iter().any(|l| matches!(
18635 l,
18636 InventoryBrowserLine::Item { text, .. } if text.contains("iron_ore")
18637 )));
18638 assert!(!lines.iter().any(|l| matches!(
18639 l,
18640 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
18641 )));
18642 assert!(!lines.iter().any(|l| matches!(
18643 l,
18644 InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
18645 )));
18646
18647 state.inventory_tab = InventoryTab::Nearby;
18648 let nearby_rows = state.inventory_selectable_rows();
18649 assert_eq!(nearby_rows.len(), 2);
18650 assert!(nearby_rows[0].is_chest_shell);
18651 assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
18652 let nearby_lines = state.inventory_browser_lines();
18653 assert!(nearby_lines.iter().any(|l| matches!(
18654 l,
18655 InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
18656 )));
18657 }
18658
18659 #[test]
18660 fn give_worker_notice_does_not_put_item_back_in_bag() {
18661 let mut state = sample_state();
18662 let id = uuid::Uuid::from_u128(42);
18663 let mut saw = flatland_protocol::ItemStack::simple("handsaw", 1);
18664 saw.item_instance_id = Some(id);
18665 saw.display_name = Some("Handsaw".into());
18666 state.sync_inventory_from_stacks(&[saw]);
18667 assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 1);
18668
18669 state.remove_carried_instance(id, None);
18670 assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 0);
18671 assert!(state.inventory_stacks.is_empty());
18672
18673 state.apply_interaction_notice(&flatland_protocol::InteractionNotice {
18674 target_id: "worker-1".into(),
18675 message: "Gave 1x Handsaw to Laborer".into(),
18676 coins_delta: 0,
18677 inventory_delta: vec![flatland_protocol::ItemStack::simple("handsaw", 1)],
18678 });
18679 assert_eq!(
18680 state.inventory.get("handsaw").copied().unwrap_or(0),
18681 0,
18682 "Gave notice must not restore the handed stack"
18683 );
18684 }
18685
18686 #[test]
18687 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
18688 let mut state = sample_state();
18689 let back_id = uuid::Uuid::from_u128(5);
18690 state.worn.insert(
18691 BodySlot::Back,
18692 flatland_protocol::ItemStack {
18693 template_id: "travel_backpack".into(),
18694 quantity: 1,
18695 item_instance_id: Some(back_id),
18696 props: Default::default(),
18697 status_bindings: Vec::new(),
18698 contents: Vec::new(),
18699 display_name: None,
18700 category: Some("container".into()),
18701 base_mass: None,
18702 base_volume: None,
18703 capacity_volume: Some(80.0),
18704 stackable: None,
18705 world_placeable: None,
18706 worker_lodging_capacity: None,
18707 equip_slot: None,
18708 armor_physical: None,
18709 resists: vec![],
18710 hand_slots: None,
18711 listable: None,
18712 ..Default::default()
18713 },
18714 );
18715 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18716 id: "chest-1".into(),
18717 template_id: "wooden_chest_small".into(),
18718 display_name: "Wooden Chest".into(),
18719 x: 129.0,
18720 y: 128.0,
18721 z: 0.0,
18722 locked: false,
18723 accessible: true,
18724 owner_character_id: None,
18725 contents: Vec::new(),
18726 lock_id: None,
18727 capacity_volume: None,
18728 item_instance_id: Some(uuid::Uuid::from_u128(6)),
18729 tile_id: None,
18730 worker_lodging_capacity: None,
18731 blocking: false,
18732 blocking_radius_m: 0.0,
18733 building_id: None,
18734 }];
18735
18736 let opts = state.move_destinations_for(
18739 &flatland_protocol::InventoryLocation::Root,
18740 None,
18741 None,
18742 "lumber",
18743 );
18744 assert!(!opts.iter().any(|o| matches!(
18745 &o.kind,
18746 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18747 )));
18748 assert!(opts.iter().any(|o| matches!(
18749 &o.kind,
18750 MoveOptionKind::Move { location, parent_instance_id, .. }
18751 if *location == flatland_protocol::InventoryLocation::Worn {
18752 slot: BodySlot::Back,
18753 } && *parent_instance_id == Some(back_id)
18754 )));
18755 assert!(opts.iter().any(|o| matches!(
18756 &o.kind,
18757 MoveOptionKind::Move { location, .. }
18758 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
18759 )));
18760 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18761 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
18762 let backpack = opts
18763 .iter()
18764 .find(|o| {
18765 matches!(
18766 &o.kind,
18767 MoveOptionKind::Move {
18768 location: flatland_protocol::InventoryLocation::Worn {
18769 slot: BodySlot::Back,
18770 },
18771 parent_instance_id,
18772 } if *parent_instance_id == Some(back_id)
18773 )
18774 })
18775 .expect("worn backpack destination");
18776 assert_eq!(backpack.volume, Some((0.0, 80.0)));
18777 assert_eq!(
18778 backpack.volume_usage_label().as_deref(),
18779 Some("vol 0/80 (80 free)")
18780 );
18781
18782 let from_backpack = flatland_protocol::InventoryLocation::Worn {
18786 slot: BodySlot::Back,
18787 };
18788 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
18789 assert!(!opts.iter().any(|o| matches!(
18790 &o.kind,
18791 MoveOptionKind::Move { location, parent_instance_id, .. }
18792 if *location == from_backpack && *parent_instance_id == Some(back_id)
18793 )));
18794 assert!(opts.iter().any(|o| matches!(
18795 &o.kind,
18796 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18797 )));
18798 }
18799
18800 #[test]
18801 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
18802 let mut state = sample_state();
18803 state.worn.insert(
18806 BodySlot::Waist,
18807 flatland_protocol::ItemStack {
18808 template_id: "simple_belt".into(),
18809 quantity: 1,
18810 item_instance_id: Some(uuid::Uuid::from_u128(10)),
18811 props: Default::default(),
18812 status_bindings: Vec::new(),
18813 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
18814 display_name: None,
18815 category: Some("container".into()),
18816 base_mass: None,
18817 base_volume: None,
18818 capacity_volume: None,
18819 stackable: None,
18820 world_placeable: None,
18821 worker_lodging_capacity: None,
18822 equip_slot: None,
18823 armor_physical: None,
18824 resists: vec![],
18825 hand_slots: None,
18826 listable: None,
18827 ..Default::default()
18828 },
18829 );
18830 state.worn.insert(
18831 BodySlot::Head,
18832 flatland_protocol::ItemStack {
18833 template_id: "cloth_cap".into(),
18834 quantity: 1,
18835 item_instance_id: Some(uuid::Uuid::from_u128(11)),
18836 props: Default::default(),
18837 status_bindings: Vec::new(),
18838 contents: Vec::new(),
18839 display_name: None,
18840 category: Some("armor".into()),
18841 base_mass: None,
18842 base_volume: None,
18843 capacity_volume: None,
18844 stackable: None,
18845 world_placeable: None,
18846 worker_lodging_capacity: None,
18847 equip_slot: None,
18848 armor_physical: None,
18849 resists: vec![],
18850 hand_slots: None,
18851 listable: None,
18852 ..Default::default()
18853 },
18854 );
18855 state.worn.insert(
18856 BodySlot::Back,
18857 flatland_protocol::ItemStack {
18858 template_id: "travel_backpack".into(),
18859 quantity: 1,
18860 item_instance_id: Some(uuid::Uuid::from_u128(12)),
18861 props: Default::default(),
18862 status_bindings: Vec::new(),
18863 contents: Vec::new(),
18864 display_name: None,
18865 category: Some("container".into()),
18866 base_mass: None,
18867 base_volume: None,
18868 capacity_volume: None,
18869 stackable: None,
18870 world_placeable: None,
18871 worker_lodging_capacity: None,
18872 equip_slot: None,
18873 armor_physical: None,
18874 resists: vec![],
18875 hand_slots: None,
18876 listable: None,
18877 ..Default::default()
18878 },
18879 );
18880
18881 let rows = state.worn_rows();
18882 assert_eq!(rows.len(), 4);
18884 assert_eq!(rows[0].stack.template_id, "cloth_cap");
18885 assert!(rows[0].is_equip_shell);
18886 assert_eq!(rows[1].stack.template_id, "travel_backpack");
18887 assert!(rows[1].is_equip_shell);
18888 assert_eq!(rows[2].stack.template_id, "simple_belt");
18889 assert!(rows[2].is_equip_shell);
18890 assert_eq!(rows[3].stack.template_id, "leather_pouch");
18891 assert_eq!(rows[3].depth, 1);
18892 assert!(!rows[3].is_equip_shell);
18893 }
18894
18895 #[test]
18896 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
18897 let mut state = sample_state();
18898 state.worn.insert(
18899 BodySlot::Waist,
18900 flatland_protocol::ItemStack {
18901 template_id: "simple_belt".into(),
18902 quantity: 1,
18903 item_instance_id: Some(uuid::Uuid::from_u128(20)),
18904 props: Default::default(),
18905 status_bindings: Vec::new(),
18906 contents: Vec::new(),
18907 display_name: Some("Simple Belt".into()),
18908 category: Some("container".into()),
18909 base_mass: None,
18910 base_volume: None,
18911 capacity_volume: None,
18912 stackable: None,
18913 world_placeable: None,
18914 worker_lodging_capacity: None,
18915 equip_slot: None,
18916 armor_physical: None,
18917 resists: vec![],
18918 hand_slots: None,
18919 listable: None,
18920 ..Default::default()
18921 },
18922 );
18923 state.worn.insert(
18924 BodySlot::Head,
18925 flatland_protocol::ItemStack {
18926 template_id: "cloth_cap".into(),
18927 quantity: 1,
18928 item_instance_id: Some(uuid::Uuid::from_u128(21)),
18929 props: Default::default(),
18930 status_bindings: Vec::new(),
18931 contents: Vec::new(),
18932 display_name: Some("Cloth Cap".into()),
18933 category: Some("armor".into()),
18934 base_mass: None,
18935 base_volume: None,
18936 capacity_volume: None,
18937 stackable: None,
18938 world_placeable: None,
18939 worker_lodging_capacity: None,
18940 equip_slot: None,
18941 armor_physical: None,
18942 resists: vec![],
18943 hand_slots: None,
18944 listable: None,
18945 ..Default::default()
18946 },
18947 );
18948
18949 let opts = state.move_destinations_for(
18950 &flatland_protocol::InventoryLocation::Root,
18951 None,
18952 None,
18953 "leather_pouch",
18954 );
18955 assert!(
18956 opts.iter().any(|o| matches!(
18957 &o.kind,
18958 MoveOptionKind::Move { location, .. }
18959 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18960 )),
18961 "belt loop must be offered when moving a pouch"
18962 );
18963 assert!(
18964 !opts.iter().any(|o| matches!(
18965 &o.kind,
18966 MoveOptionKind::Move { location, .. }
18967 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
18968 )),
18969 "armor slots can't hold other items and must not appear as move destinations"
18970 );
18971 let belt_opt = opts
18972 .iter()
18973 .find(|o| matches!(
18974 &o.kind,
18975 MoveOptionKind::Move { location, .. }
18976 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18977 ))
18978 .unwrap();
18979 assert!(belt_opt.label.contains("belt loop"));
18980
18981 let opts = state.move_destinations_for(
18982 &flatland_protocol::InventoryLocation::Root,
18983 None,
18984 None,
18985 "lumber",
18986 );
18987 assert!(
18988 !opts.iter().any(|o| o.label.contains("belt loop")),
18989 "loose materials must not target the belt shell — only nested pouches"
18990 );
18991 }
18992
18993 #[test]
18994 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
18995 let mut state = sample_state();
18996 let belt_id = uuid::Uuid::from_u128(30);
18997 let pouch_id = uuid::Uuid::from_u128(31);
18998 state.worn.insert(
18999 BodySlot::Waist,
19000 flatland_protocol::ItemStack {
19001 template_id: "simple_belt".into(),
19002 quantity: 1,
19003 item_instance_id: Some(belt_id),
19004 props: Default::default(),
19005 status_bindings: Vec::new(),
19006 world_placeable: None,
19007 worker_lodging_capacity: None,
19008 equip_slot: None,
19009 armor_physical: None,
19010 resists: vec![],
19011 hand_slots: None,
19012 contents: vec![flatland_protocol::ItemStack {
19013 template_id: "dimensional_pouch".into(),
19014 quantity: 1,
19015 item_instance_id: Some(pouch_id),
19016 props: Default::default(),
19017 status_bindings: Vec::new(),
19018 contents: Vec::new(),
19019 display_name: Some("Dimensional Pouch".into()),
19020 category: Some("container".into()),
19021 base_mass: None,
19022 base_volume: None,
19023 capacity_volume: Some(200.0),
19024 stackable: None,
19025 world_placeable: None,
19026 worker_lodging_capacity: None,
19027 equip_slot: None,
19028 armor_physical: None,
19029 resists: vec![],
19030 hand_slots: None,
19031 listable: None,
19032 ..Default::default()
19033 }],
19034 display_name: Some("Simple Belt".into()),
19035 category: Some("container".into()),
19036 base_mass: None,
19037 base_volume: None,
19038 capacity_volume: None,
19039 stackable: None,
19040 listable: None,
19041 ..Default::default()
19042 },
19043 );
19044
19045 let opts = state.move_destinations_for(
19046 &flatland_protocol::InventoryLocation::Root,
19047 None,
19048 None,
19049 "iron_ore",
19050 );
19051 assert!(
19052 opts.iter().any(|o| matches!(
19053 &o.kind,
19054 MoveOptionKind::Move {
19055 location,
19056 parent_instance_id,
19057 ..
19058 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
19059 && *parent_instance_id == Some(pouch_id)
19060 )),
19061 "dimensional pouch clipped on belt must accept loose items"
19062 );
19063 assert!(
19064 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
19065 "destination label should name the pouch"
19066 );
19067 }
19068
19069 #[test]
19070 fn container_volume_label_on_placed_chest_shell() {
19071 let mut state = sample_state();
19072 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19073 id: "chest-1".into(),
19074 template_id: "wooden_chest_small".into(),
19075 display_name: "Camp Chest".into(),
19076 x: 129.0,
19077 y: 128.0,
19078 z: 0.0,
19079 locked: false,
19080 accessible: true,
19081 owner_character_id: None,
19082 contents: vec![flatland_protocol::ItemStack {
19083 template_id: "iron_ore".into(),
19084 quantity: 2,
19085 item_instance_id: None,
19086 props: Default::default(),
19087 status_bindings: Vec::new(),
19088 contents: Vec::new(),
19089 display_name: None,
19090 category: None,
19091 base_mass: None,
19092 base_volume: Some(2.0),
19093 capacity_volume: None,
19094 stackable: None,
19095 world_placeable: None,
19096 worker_lodging_capacity: None,
19097 equip_slot: None,
19098 armor_physical: None,
19099 resists: vec![],
19100 hand_slots: None,
19101 listable: None,
19102 ..Default::default()
19103 }],
19104 lock_id: None,
19105 capacity_volume: Some(60.0),
19106 item_instance_id: Some(uuid::Uuid::from_u128(4)),
19107 tile_id: None,
19108 worker_lodging_capacity: None,
19109 blocking: false,
19110 blocking_radius_m: 0.0,
19111 building_id: None,
19112 }];
19113 let nearby = state.nearby_containers();
19114 let label = state.container_volume_label(&nearby[0].rows[0]);
19115 assert!(
19116 label.contains("vol 4/60"),
19117 "expected used/cap in label, got {label}"
19118 );
19119 assert!(
19120 label.contains("56 free"),
19121 "expected free space, got {label}"
19122 );
19123 }
19124
19125 #[test]
19126 fn move_destinations_for_include_placed_chest_volume() {
19127 let mut state = sample_state();
19128 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19129 id: "chest-1".into(),
19130 template_id: "wooden_chest_small".into(),
19131 display_name: "Camp Chest".into(),
19132 x: 129.0,
19133 y: 128.0,
19134 z: 0.0,
19135 locked: false,
19136 accessible: true,
19137 owner_character_id: None,
19138 contents: vec![flatland_protocol::ItemStack {
19139 template_id: "iron_ore".into(),
19140 quantity: 2,
19141 item_instance_id: None,
19142 props: Default::default(),
19143 status_bindings: Vec::new(),
19144 contents: Vec::new(),
19145 display_name: None,
19146 category: None,
19147 base_mass: None,
19148 base_volume: Some(2.0),
19149 capacity_volume: None,
19150 stackable: None,
19151 world_placeable: None,
19152 worker_lodging_capacity: None,
19153 equip_slot: None,
19154 armor_physical: None,
19155 resists: vec![],
19156 hand_slots: None,
19157 listable: None,
19158 ..Default::default()
19159 }],
19160 lock_id: None,
19161 capacity_volume: Some(60.0),
19162 item_instance_id: Some(uuid::Uuid::from_u128(4)),
19163 tile_id: None,
19164 worker_lodging_capacity: None,
19165 blocking: false,
19166 blocking_radius_m: 0.0,
19167 building_id: None,
19168 }];
19169 let opts = state.move_destinations_for(
19170 &flatland_protocol::InventoryLocation::Root,
19171 None,
19172 None,
19173 "lumber",
19174 );
19175 let chest = opts
19176 .iter()
19177 .find(|o| {
19178 matches!(
19179 &o.kind,
19180 MoveOptionKind::Move {
19181 location: flatland_protocol::InventoryLocation::Placed { container_id },
19182 ..
19183 } if container_id == "chest-1"
19184 )
19185 })
19186 .expect("nearby chest destination");
19187 assert_eq!(chest.volume, Some((4.0, 60.0)));
19188 assert_eq!(
19189 chest.volume_usage_label().as_deref(),
19190 Some("vol 4/60 (56 free)")
19191 );
19192 assert!(opts
19193 .iter()
19194 .filter(|o| matches!(o.kind, MoveOptionKind::Drop | MoveOptionKind::Cancel))
19195 .all(|o| o.volume.is_none()));
19196 }
19197
19198 #[test]
19199 fn chest_pickup_destinations_include_worn_bag_volume() {
19200 let mut state = sample_state();
19201 let back_id = uuid::Uuid::from_u128(5);
19202 state.worn.insert(
19203 BodySlot::Back,
19204 flatland_protocol::ItemStack {
19205 template_id: "travel_backpack".into(),
19206 quantity: 1,
19207 item_instance_id: Some(back_id),
19208 props: Default::default(),
19209 status_bindings: Vec::new(),
19210 contents: Vec::new(),
19211 display_name: Some("Travel Backpack".into()),
19212 category: Some("container".into()),
19213 capacity_volume: Some(80.0),
19214 ..Default::default()
19215 },
19216 );
19217 let opts = state.chest_pickup_destinations("chest-1");
19218 let bag = opts
19219 .iter()
19220 .find(|o| {
19221 matches!(
19222 &o.kind,
19223 MoveOptionKind::PickupPlaced {
19224 nest_parent_instance_id,
19225 ..
19226 } if *nest_parent_instance_id == Some(back_id)
19227 )
19228 })
19229 .expect("pickup into worn backpack");
19230 assert_eq!(bag.volume, Some((0.0, 80.0)));
19231 assert!(opts
19232 .iter()
19233 .filter(|o| matches!(
19234 o.kind,
19235 MoveOptionKind::RelocatePlaced { .. } | MoveOptionKind::Cancel
19236 ))
19237 .all(|o| o.volume.is_none()));
19238 }
19239
19240 #[test]
19241 fn key_pair_chest_label_from_placed_lock_id() {
19242 let mut state = sample_state();
19243 let owner = uuid::Uuid::from_u128(77);
19244 state.character_id = Some(owner);
19245 let lock = uuid::Uuid::from_u128(99).to_string();
19246 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19247 id: "chest-1".into(),
19248 template_id: "wooden_chest_small".into(),
19249 display_name: "Barry's Loot #a3f2".into(),
19250 x: 129.0,
19251 y: 128.0,
19252 z: 0.0,
19253 locked: true,
19254 accessible: true,
19255 owner_character_id: Some(owner),
19256 contents: Vec::new(),
19257 lock_id: Some(lock.clone()),
19258 capacity_volume: None,
19259 item_instance_id: Some(uuid::Uuid::from_u128(4)),
19260 tile_id: None,
19261 worker_lodging_capacity: None,
19262 blocking: false,
19263 blocking_radius_m: 0.0,
19264 building_id: None,
19265 }];
19266 let key_id = uuid::Uuid::from_u128(5);
19267 let key = flatland_protocol::ItemStack {
19268 template_id: KEY_TEMPLATE.into(),
19269 quantity: 1,
19270 item_instance_id: Some(key_id),
19271 props: BTreeMap::from([
19272 (PROP_OPENS_LOCK_ID.into(), lock),
19273 (
19274 PROP_OPENS_CONTAINER_NAME.into(),
19275 "Barry's Loot #a3f2".into(),
19276 ),
19277 ]),
19278 status_bindings: Vec::new(),
19279 contents: Vec::new(),
19280 display_name: Some("Container Key".into()),
19281 category: Some("key".into()),
19282 base_mass: None,
19283 base_volume: None,
19284 capacity_volume: None,
19285 stackable: None,
19286 world_placeable: None,
19287 worker_lodging_capacity: None,
19288 equip_slot: None,
19289 armor_physical: None,
19290 resists: vec![],
19291 hand_slots: None,
19292 listable: None,
19293 ..Default::default()
19294 };
19295 state.inventory_stacks = vec![key.clone()];
19296 assert_eq!(
19297 state.key_pair_chest_label(&key).as_deref(),
19298 Some("Barry's Loot #a3f2")
19299 );
19300 assert!(state.key_drop_blocked(&key));
19301 }
19302
19303 #[test]
19304 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
19305 let mut state = sample_state();
19306 let lock = uuid::Uuid::from_u128(101).to_string();
19307 let key = flatland_protocol::ItemStack {
19308 template_id: KEY_TEMPLATE.into(),
19309 quantity: 1,
19310 item_instance_id: Some(uuid::Uuid::from_u128(7)),
19311 props: BTreeMap::from([
19312 (PROP_OPENS_LOCK_ID.into(), lock),
19313 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
19314 ]),
19315 status_bindings: Vec::new(),
19316 contents: Vec::new(),
19317 display_name: None,
19318 category: Some("key".into()),
19319 base_mass: None,
19320 base_volume: None,
19321 capacity_volume: None,
19322 stackable: None,
19323 world_placeable: None,
19324 worker_lodging_capacity: None,
19325 equip_slot: None,
19326 armor_physical: None,
19327 resists: vec![],
19328 hand_slots: None,
19329 listable: None,
19330 ..Default::default()
19331 };
19332 state.placed_containers.clear();
19333 assert_eq!(
19334 state.key_pair_chest_label(&key).as_deref(),
19335 Some("Camp Stash")
19336 );
19337 }
19338
19339 #[test]
19340 fn key_drop_allowed_when_paired_chest_unlocked() {
19341 let mut state = sample_state();
19342 let lock = uuid::Uuid::from_u128(100).to_string();
19343 let key_id = uuid::Uuid::from_u128(6);
19344 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19345 id: "chest-1".into(),
19346 template_id: "wooden_chest_small".into(),
19347 display_name: "Camp Chest".into(),
19348 x: 129.0,
19349 y: 128.0,
19350 z: 0.0,
19351 locked: false,
19352 accessible: true,
19353 owner_character_id: None,
19354 contents: Vec::new(),
19355 lock_id: Some(lock.clone()),
19356 capacity_volume: None,
19357 item_instance_id: None,
19358 tile_id: None,
19359 worker_lodging_capacity: None,
19360 blocking: false,
19361 blocking_radius_m: 0.0,
19362 building_id: None,
19363 }];
19364 let key = flatland_protocol::ItemStack {
19365 template_id: KEY_TEMPLATE.into(),
19366 quantity: 1,
19367 item_instance_id: Some(key_id),
19368 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
19369 status_bindings: Vec::new(),
19370 contents: Vec::new(),
19371 display_name: None,
19372 category: Some("key".into()),
19373 base_mass: None,
19374 base_volume: None,
19375 capacity_volume: None,
19376 stackable: None,
19377 world_placeable: None,
19378 worker_lodging_capacity: None,
19379 equip_slot: None,
19380 armor_physical: None,
19381 resists: vec![],
19382 hand_slots: None,
19383 listable: None,
19384 ..Default::default()
19385 };
19386 state.inventory_stacks = vec![key.clone()];
19387 assert!(!state.key_drop_blocked(&key));
19388 let opts = state.move_destinations_for(
19389 &flatland_protocol::InventoryLocation::Root,
19390 None,
19391 Some(key_id),
19392 KEY_TEMPLATE,
19393 );
19394 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
19395 }
19396
19397 #[test]
19398 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
19399 use flatland_protocol::{CombatHud, ProgressionCurve, ProgressionXp};
19400
19401 let mut state = sample_state();
19402 let curve = ProgressionCurve::default();
19403 let bootstrap =
19404 ProgressionXp::bootstrap_new(curve.baseline_display, curve.xp_base, curve.xp_growth);
19405 let mut fresh = bootstrap.clone();
19406 fresh.strength += 0.08;
19407 if let Some(player) = state.player.as_mut() {
19408 player.progression_xp = Some(bootstrap);
19409 }
19410
19411 let combat = CombatHud {
19412 progression_xp: Some(fresh.clone()),
19413 progression_baseline: curve.baseline_display,
19414 progression_xp_base: curve.xp_base,
19415 progression_xp_growth: curve.xp_growth,
19416 attributes: state.player.as_ref().and_then(|p| p.attributes),
19417 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
19418 ..CombatHud::default()
19419 };
19420 state.apply_combat_hud(&combat);
19421
19422 let xp = state
19423 .player
19424 .as_ref()
19425 .and_then(|p| p.progression_xp.as_ref())
19426 .expect("xp");
19427 assert!((xp.strength - fresh.strength).abs() < 0.001);
19428 assert!(state.progression_curve.is_some());
19429 }
19430
19431 #[test]
19432 fn combat_hud_syncs_known_abilities_and_hotbar() {
19433 use flatland_protocol::CombatHud;
19434
19435 let mut state = sample_state();
19436 let combat = CombatHud {
19437 known_abilities: vec!["unarmed".into(), "fireball".into()],
19438 hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
19439 max_abilities_per_rotation: 4,
19440 ability_id: "short_sword_slash".into(),
19441 ..CombatHud::default()
19442 };
19443 state.apply_combat_hud(&combat);
19444
19445 assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
19446 assert_eq!(state.hotbar_ability(1), Some("fireball"));
19447 assert_eq!(state.hotbar_ability(2), None);
19448 assert_eq!(state.hotbar_ability(3), Some("unarmed"));
19449 assert_eq!(state.max_abilities_per_rotation, 4);
19450 let choices = state.loadout_ability_choices();
19451 assert!(choices.iter().any(|a| a == "short_sword_slash"));
19452 assert!(choices.iter().any(|a| a == "fireball"));
19453 }
19454
19455 #[test]
19456 fn loadout_hotbar_choices_include_inventory_consumables() {
19457 let mut state = sample_state();
19458 state.known_abilities = vec!["unarmed".into()];
19459 state.weapon_ability_id = "unarmed".into();
19460 state.inventory_stacks = vec![flatland_protocol::ItemStack {
19461 template_id: "empty_bottle".into(),
19462 quantity: 1,
19463 item_instance_id: Some(uuid::Uuid::from_u128(9)),
19464 display_name: Some("Glass Bottle of Water".into()),
19465 category: Some("container".into()),
19466 props: [
19467 ("serving".into(), "1".into()),
19468 ("liquid_vessel".into(), "1".into()),
19469 ("serving_holds".into(), "liquid".into()),
19470 ]
19471 .into_iter()
19472 .collect(),
19473 ..Default::default()
19474 }];
19475 state.inventory.insert("empty_bottle".into(), 1);
19476 state.inventory_hints.insert(
19477 "empty_bottle".into(),
19478 InventoryHint {
19479 display_name: "Glass Bottle".into(),
19480 category: "container".into(),
19481 ..Default::default()
19482 },
19483 );
19484
19485 let choices = state.loadout_hotbar_choices();
19486 assert!(choices.iter().any(|c| c.binding == "unarmed"));
19487 let water = choices
19488 .iter()
19489 .find(|c| c.binding == "item:empty_bottle")
19490 .expect("serving bottle binding");
19491 assert_eq!(water.meta.as_deref(), Some("use"));
19492 assert!(water.label.contains("Glass Bottle of Water"));
19493 assert_eq!(state.hotbar_slot_label(1), None, "unbound until set");
19494 state.hotbar = vec![None, None, None, None, Some("item:empty_bottle".into())];
19495 assert_eq!(
19496 state.hotbar_slot_label(5).as_deref(),
19497 Some("Glass Bottle×1")
19498 );
19499 }
19500
19501 #[test]
19502 fn loadout_hotbar_choices_exclude_blueprint_scrolls() {
19503 let mut state = sample_state();
19504 state.known_abilities = vec!["unarmed".into()];
19505 state.weapon_ability_id = "unarmed".into();
19506 state.inventory_stacks = vec![
19507 flatland_protocol::ItemStack {
19508 template_id: "carrot".into(),
19509 quantity: 2,
19510 display_name: Some("Wild Carrot".into()),
19511 category: Some("consumable".into()),
19512 ..Default::default()
19513 },
19514 flatland_protocol::ItemStack {
19515 template_id: "blueprint_dimensional_pouch".into(),
19516 quantity: 1,
19517 display_name: Some("Blueprint — Dimensional Pouch".into()),
19518 category: Some("consumable".into()),
19519 props: [("teaches_blueprint".into(), "craft_dimensional_pouch".into())]
19520 .into_iter()
19521 .collect(),
19522 ..Default::default()
19523 },
19524 ];
19525 state.inventory.insert("carrot".into(), 2);
19526 state
19527 .inventory
19528 .insert("blueprint_dimensional_pouch".into(), 1);
19529 state.inventory_hints.insert(
19530 "carrot".into(),
19531 InventoryHint {
19532 display_name: "Wild Carrot".into(),
19533 category: "consumable".into(),
19534 ..Default::default()
19535 },
19536 );
19537 state.inventory_hints.insert(
19538 "blueprint_dimensional_pouch".into(),
19539 InventoryHint {
19540 display_name: "Blueprint — Dimensional Pouch".into(),
19541 category: "consumable".into(),
19542 ..Default::default()
19543 },
19544 );
19545
19546 let choices = state.loadout_hotbar_choices();
19547 assert!(choices.iter().any(|c| c.binding == "item:carrot"));
19548 assert!(
19549 choices
19550 .iter()
19551 .all(|c| c.binding != "item:blueprint_dimensional_pouch"),
19552 "recipe scrolls must not appear on the hotbar picker: {choices:?}"
19553 );
19554 }
19555
19556 #[test]
19557 fn storage_store_options_excludes_hand_equipped() {
19558 let mut state = sample_state();
19559 let sword_id = uuid::Uuid::from_u128(11);
19560 let ore_id = uuid::Uuid::from_u128(22);
19561 state.inventory_stacks = vec![
19562 flatland_protocol::ItemStack {
19563 template_id: "short_sword".into(),
19564 quantity: 1,
19565 item_instance_id: Some(sword_id),
19566 display_name: Some("Short Sword".into()),
19567 category: Some("weapon".into()),
19568 ..Default::default()
19569 },
19570 flatland_protocol::ItemStack {
19571 template_id: "iron_ore".into(),
19572 quantity: 5,
19573 item_instance_id: Some(ore_id),
19574 display_name: Some("Iron Ore".into()),
19575 category: Some("resource".into()),
19576 ..Default::default()
19577 },
19578 ];
19579 state.mainhand_template_id = Some("short_sword".into());
19580 state.mainhand_instance_id = Some(sword_id);
19581
19582 let opts = state.storage_store_options();
19583 assert_eq!(opts.len(), 1);
19584 assert_eq!(opts[0].item_instance_id, ore_id);
19585 assert!(state.hand_equipped_instance_ids().contains(&sword_id));
19586 }
19587
19588 #[test]
19589 fn loose_consumable_move_picker_offers_use_and_storage() {
19590 let mut state = sample_state();
19591 let inst = uuid::Uuid::from_u128(77);
19592 state.inventory_stacks = vec![flatland_protocol::ItemStack {
19593 template_id: "carrot".into(),
19594 quantity: 2,
19595 item_instance_id: Some(inst),
19596 props: Default::default(),
19597 status_bindings: Vec::new(),
19598 contents: Vec::new(),
19599 display_name: Some("Wild Carrot".into()),
19600 category: Some("consumable".into()),
19601 base_mass: None,
19602 base_volume: None,
19603 capacity_volume: None,
19604 stackable: Some(true),
19605 world_placeable: None,
19606 worker_lodging_capacity: None,
19607 equip_slot: None,
19608 armor_physical: None,
19609 resists: vec![],
19610 hand_slots: None,
19611 listable: None,
19612 ..Default::default()
19613 }];
19614 state.inventory_hints.insert(
19615 "carrot".into(),
19616 InventoryHint {
19617 display_name: "Wild Carrot".into(),
19618 category: "consumable".into(),
19619 base_mass: Some(0.15),
19620 base_volume: Some(0.3),
19621 capacity_volume: None,
19622 stackable: true,
19623 listable: true,
19624 base_value_copper: None,
19625 },
19626 );
19627 state.show_inventory_menu = true;
19628 state.inventory_menu_index = 0;
19629
19630 let row = state.inventory_selected_row().expect("carrot row");
19631 let mut options = state.move_destinations_for(
19632 &row.from,
19633 row.from_parent_instance_id,
19634 row.stack.item_instance_id,
19635 &row.stack.template_id,
19636 );
19637 if row.from == flatland_protocol::InventoryLocation::Root
19638 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
19639 {
19640 options.insert(
19641 0,
19642 MoveOption::action("Use (eat / drink)", MoveOptionKind::Use),
19643 );
19644 }
19645
19646 assert_eq!(
19647 options.first().map(|o| &o.label),
19648 Some(&"Use (eat / drink)".into())
19649 );
19650 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
19651 assert!(options
19652 .iter()
19653 .any(|o| matches!(o.kind, MoveOptionKind::Drop)));
19654 }
19655
19656 #[test]
19657 fn inventory_category_group_order_is_stable() {
19658 assert_eq!(inventory_category_group("weapon").0, "Weapons");
19659 assert_eq!(inventory_category_group("armor").0, "Armor");
19660 assert_eq!(inventory_category_group("consumable").0, "Consumables");
19661 assert_eq!(inventory_category_group("liquid").0, "Consumables");
19662 assert_eq!(inventory_category_group("resource").0, "Resources");
19663 assert_eq!(inventory_category_group("container").0, "Containers");
19664 assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
19665 assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
19666 }
19667
19668 #[test]
19669 fn page_list_index_clamps_without_wrap() {
19670 assert_eq!(page_list_index(0, -1, 25), 0);
19671 assert_eq!(page_list_index(0, 1, 25), 10);
19672 assert_eq!(page_list_index(12, 1, 25), 22);
19673 assert_eq!(page_list_index(22, 1, 25), 24);
19674 assert_eq!(page_list_index(5, 1, 0), 0);
19675 assert_eq!(page_list_index(3, -1, 8), 0);
19676 }
19677
19678 #[test]
19679 fn inventory_filter_hides_non_matching_person_items() {
19680 let mut state = sample_state();
19681 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19682 sword.display_name = Some("Iron Sword".into());
19683 sword.category = Some("weapon".into());
19684 let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
19685 herb.display_name = Some("Wild Herb".into());
19686 herb.category = Some("consumable".into());
19687 state.inventory_stacks = vec![sword, herb];
19688 state.inventory_tab = InventoryTab::OnPerson;
19689 state.inventory_filter = "sword".into();
19690
19691 let rows = state.inventory_selectable_rows();
19692 assert_eq!(rows.len(), 1);
19693 assert_eq!(rows[0].stack.template_id, "iron_sword");
19694
19695 let lines = state.inventory_browser_lines();
19696 assert!(lines.iter().any(|l| matches!(
19697 l,
19698 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
19699 )));
19700 assert!(!lines.iter().any(|l| matches!(
19701 l,
19702 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
19703 )));
19704 }
19705
19706 #[test]
19707 fn list_filter_chars_reject_mac_arrow_glyphs() {
19708 assert!(is_list_filter_char('a'));
19709 assert!(is_list_filter_char(' '));
19710 assert!(is_list_filter_char('-'));
19711 assert!(!is_list_filter_char('\u{F700}'));
19712 assert!(!is_list_filter_char('\u{F701}'));
19713 assert!(!is_list_filter_char('\n'));
19714 }
19715
19716 #[test]
19717 fn ready_tab_keeps_in_progress_craft_after_inputs_spent() {
19718 let mut state = sample_state();
19719 state.craft_tab = CraftTab::Ready;
19720 state.blueprints = vec![BlueprintView {
19721 id: "plank".into(),
19722 label: "Plank".into(),
19723 craft_tier: 1,
19724 craft_ticks: 30,
19725 output: "wood_plank".into(),
19726 output_qty: 1,
19727 output_display_name: "Wood Plank".into(),
19728 station: None,
19729 category: None,
19730 inputs: vec![flatland_protocol::BlueprintIngredientView {
19731 template_id: "oak_log".into(),
19732 quantity: 1,
19733 consumed: true,
19734 display_name: "Oak Log".into(),
19735 }],
19736 required_tools: vec![],
19737 skill: None,
19738 failure_chance: 0.0,
19739 worker_train_copper: 0,
19740 }];
19741 state.inventory.clear();
19743 state.craft_channel_blueprint_id = Some("plank".into());
19744 state.timed_channel = Some(flatland_protocol::TimedChannelHud {
19745 label: "Crafting Plank".into(),
19746 channel: flatland_protocol::TimedChannelKind::Craft,
19747 ticks_remaining: 20,
19748 ticks_total: 30,
19749 ..Default::default()
19750 });
19751
19752 let idxs = state.craft_filtered_indices();
19753 assert_eq!(idxs, vec![0]);
19754 assert!(state.craft_blueprint_in_channel("plank"));
19755
19756 state.timed_channel = None;
19758 state.craft_channel_blueprint_id = None;
19759 assert!(state.craft_filtered_indices().is_empty());
19760 }
19761
19762 #[test]
19763 fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
19764 let mut state = sample_state();
19765 let id_a = uuid::Uuid::from_u128(0xa1);
19766 let id_b = uuid::Uuid::from_u128(0xb2);
19767 let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
19768 sword_a.display_name = Some("Iron Sword".into());
19769 sword_a.category = Some("weapon".into());
19770 sword_a.item_instance_id = Some(id_a);
19771 let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
19772 sword_b.display_name = Some("Iron Sword".into());
19773 sword_b.category = Some("weapon".into());
19774 sword_b.item_instance_id = Some(id_b);
19775 state.inventory_stacks = vec![sword_a, sword_b];
19776 state.inventory_tab = InventoryTab::OnPerson;
19777
19778 let lines = state.inventory_browser_lines();
19779 let items: Vec<_> = lines
19780 .iter()
19781 .filter_map(|l| match l {
19782 InventoryBrowserLine::Item {
19783 title,
19784 instance_tooltip,
19785 ..
19786 } => Some((title.clone(), instance_tooltip.clone())),
19787 _ => None,
19788 })
19789 .collect();
19790 assert_eq!(items.len(), 2);
19791 for (title, tip) in &items {
19792 assert!(
19793 !title.contains('#'),
19794 "title should not show instance suffix: {title}"
19795 );
19796 assert!(
19797 tip.is_some(),
19798 "two identical rows should expose instance on hover"
19799 );
19800 }
19801
19802 state.inventory_stacks.pop();
19803 let lines = state.inventory_browser_lines();
19804 let one = lines.iter().find_map(|l| match l {
19805 InventoryBrowserLine::Item {
19806 title,
19807 instance_tooltip,
19808 ..
19809 } => Some((title.clone(), instance_tooltip.clone())),
19810 _ => None,
19811 });
19812 let (title, tip) = one.expect("one sword row");
19813 assert!(!title.contains('#'));
19814 assert!(tip.is_none(), "single row should not need instance tooltip");
19815 }
19816
19817 #[test]
19818 fn inventory_person_rows_group_by_category() {
19819 let mut state = sample_state();
19820 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19821 sword.category = Some("weapon".into());
19822 sword.display_name = Some("Iron Sword".into());
19823 let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
19824 ore.category = Some("resource".into());
19825 ore.display_name = Some("Iron Ore".into());
19826 let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
19827 potion.category = Some("consumable".into());
19828 potion.display_name = Some("Health Potion".into());
19829 state.inventory_stacks = vec![ore, potion, sword];
19830 state.inventory_tab = InventoryTab::OnPerson;
19831
19832 let lines = state.inventory_browser_lines();
19833 let labels: Vec<&str> = lines
19834 .iter()
19835 .filter_map(|l| match l {
19836 InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
19837 _ => None,
19838 })
19839 .collect();
19840 assert!(
19841 labels.iter().any(|s| s.contains("Weapons")),
19842 "expected Weapons group: {labels:?}"
19843 );
19844 assert!(labels.iter().any(|s| s.contains("Consumables")));
19845 assert!(labels.iter().any(|s| s.contains("Resources")));
19846
19847 let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
19848 let consumable_pos = labels
19849 .iter()
19850 .position(|s| s.contains("Consumables"))
19851 .unwrap();
19852 let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
19853 assert!(weapon_pos < consumable_pos);
19854 assert!(consumable_pos < resource_pos);
19855 }
19856
19857 #[test]
19858 fn inventory_tab_cycle_resets_selection() {
19859 let mut state = sample_state();
19860 state.inventory_tab = InventoryTab::OnPerson;
19861 state.inventory_menu_index = 3;
19862 state.inventory_tab = state.inventory_tab.cycle(true);
19863 assert_eq!(state.inventory_tab, InventoryTab::Nearby);
19864 assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
19866 assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
19867 assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
19868 assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
19869 }
19870
19871 #[test]
19872 fn parse_bank_copper_amount_blank_and_zero_mean_all() {
19873 assert_eq!(parse_bank_copper_amount(""), Some(0));
19874 assert_eq!(parse_bank_copper_amount(" "), Some(0));
19875 assert_eq!(parse_bank_copper_amount("0"), Some(0));
19876 assert_eq!(parse_bank_copper_amount("250"), Some(250));
19877 assert_eq!(parse_bank_copper_amount("nope"), None);
19878 }
19879
19880 #[test]
19881 fn parse_storage_quantity_blank_and_zero_mean_all() {
19882 assert_eq!(parse_storage_quantity(""), Some(None));
19883 assert_eq!(parse_storage_quantity(" "), Some(None));
19884 assert_eq!(parse_storage_quantity("0"), Some(None));
19885 assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
19886 assert_eq!(parse_storage_quantity("nope"), None);
19887 }
19888
19889 #[test]
19890 fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
19891 assert!(worker_error_is_hud_noise("path stuck — repathing"));
19892 assert!(worker_error_is_hud_noise(
19893 "path stuck — nudged clear, repathing"
19894 ));
19895 assert!(worker_error_is_hud_noise(
19896 "returned to lodging after path failures"
19897 ));
19898 assert!(!worker_error_is_hud_noise(
19900 "path stuck — no lodging to reset to"
19901 ));
19902 assert!(!worker_error_is_hud_noise("cannot reach Eli — idling"));
19903 assert!(worker_error_is_hud_noise(
19904 "path unreachable (plan failures 0, leg 0)"
19905 ));
19906 assert!(!worker_error_is_hud_noise(
19907 "path unreachable (plan failures 0, leg 24)"
19908 ));
19909 assert!(worker_error_is_transient("storage full; continuing route"));
19910 assert!(!worker_error_is_transient(
19911 "storage full (Food Bank) — free chest space or reassign deposit"
19912 ));
19913 assert!(!worker_error_is_hud_noise(
19914 "storage full (Food Bank) — free chest space or reassign deposit"
19915 ));
19916 }
19917
19918 #[test]
19919 fn leaving_building_restores_outdoor_z_bands() {
19920 use flatland_protocol::{InteriorMapView, ZPlatformView};
19921
19922 let mut state = sample_state();
19923 state.z_platforms.clear();
19924 state.z_transitions.clear();
19925 state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
19926 state.interior_map = Some(InteriorMapView {
19927 building_id: "broker_hut".into(),
19928 blueprint_id: "broker_hut".into(),
19929 background_color: "#000".into(),
19930 default_floor_color: None,
19931 floor_height_m: 3.0,
19932 z_platforms: vec![ZPlatformView {
19933 id: "floor_0".into(),
19934 z: 0.0,
19935 x0: 0.0,
19936 y0: 0.0,
19937 x1: 8.0,
19938 y1: 8.0,
19939 }],
19940 z_transitions: vec![],
19941 rooms: vec![],
19942 room_doors: vec![],
19943 });
19944 state.sync_interior_map_context();
19945 assert_eq!(
19946 state.z_platforms.len(),
19947 1,
19948 "indoors installs interior platforms"
19949 );
19950 assert!(state.z_bands_outdoor_backup.is_some());
19951
19952 state.player.as_mut().unwrap().inside_building = None;
19953 state.sync_interior_map_context();
19954 assert!(
19955 state.z_platforms.is_empty(),
19956 "leaving must restore outdoor bands (empty), not leave interior platforms"
19957 );
19958 assert!(state.z_bands_outdoor_backup.is_none());
19959 assert!(state.interior_map.is_none());
19960 }
19961
19962 #[test]
19963 fn resource_node_route_label_prefers_friendly_label_with_suffix() {
19964 let node = ResourceNodeView {
19965 id: "crop-carrot-1_copy10".into(),
19966 label: "crop-carrot-1_copy10".into(),
19967 x: 0.0,
19968 y: 0.0,
19969 z: 0.0,
19970 item_template: "carrot".into(),
19971 state: ResourceNodeState::Available,
19972 blocking: false,
19973 blocking_radius_m: 0.5,
19974 harvest_off: false,
19975 tile_id: None,
19976 yaw: 0.0,
19977 pitch: 0.0,
19978 roll: 0.0,
19979 draw_scale: 1.0,
19980 sprite_mode: None,
19981 growth_progress: None,
19982 presentation_state: None,
19983 channel_start_tick: None,
19984 channel_end_tick: None,
19985 harvest_drop_templates: vec![],
19986 };
19987 let label = super::resource_node_route_label(&node);
19988 assert!(label.starts_with("Carrot ("), "got {label}");
19989 assert!(label.ends_with(')'), "got {label}");
19990
19991 let mut named = node;
19992 named.label = "Sweet Pad".into();
19993 named.id = "crop-carrot-a3f2b1c0".into();
19994 assert_eq!(super::resource_node_route_label(&named), "Sweet Pad (b1c0)");
19995 }
19996
19997 #[test]
19998 fn plot_public_label_uses_owner_zone_and_label() {
19999 let plot = flatland_protocol::PropertyPlotView {
20000 plot_id: uuid::Uuid::nil(),
20001 property_zone_id: "zone_a".into(),
20002 zone_label: Some("Starter Town East 1".into()),
20003 deed_instance_id: uuid::Uuid::nil(),
20004 x0: 0.0,
20005 y0: 0.0,
20006 x1: 4.0,
20007 y1: 4.0,
20008 upkeep_copper_per_day: 1,
20009 arrears_days: 0,
20010 is_mine: true,
20011 may_farm: true,
20012 purchase_basis_copper: 0,
20013 farm_public: false,
20014 public_tax_discount_bps: 0,
20015 farm_allow: vec![],
20016 owner_character_id: None,
20017 owner_label: Some("Madsin".into()),
20018 building_id: None,
20019 plot_code: "xyz1234a".into(),
20020 label: "Food Pad".into(),
20021 };
20022 assert_eq!(
20023 super::plot_public_label(&plot),
20024 "Madsin — Starter Town East 1 — Food Pad"
20025 );
20026 }
20027
20028 #[test]
20029 fn plot_public_label_uses_size_when_label_and_code_blank() {
20030 let plot = flatland_protocol::PropertyPlotView {
20031 plot_id: uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap(),
20032 property_zone_id: String::new(),
20033 zone_label: None,
20034 deed_instance_id: uuid::Uuid::nil(),
20035 x0: 10.0,
20036 y0: 20.0,
20037 x1: 18.0,
20038 y1: 28.0,
20039 upkeep_copper_per_day: 1,
20040 arrears_days: 0,
20041 is_mine: true,
20042 may_farm: true,
20043 purchase_basis_copper: 0,
20044 farm_public: false,
20045 public_tax_discount_bps: 0,
20046 farm_allow: vec![],
20047 owner_character_id: None,
20048 owner_label: None,
20049 building_id: None,
20050 plot_code: String::new(),
20051 label: String::new(),
20052 };
20053 assert_eq!(super::plot_public_label(&plot), "Homestead — Plot (8×8 m)");
20054 assert!(!super::plot_public_label(&plot).contains("19fe35f"));
20055 }
20056
20057 #[test]
20058 fn plot_stop_label_prefers_view_over_hex() {
20059 let plot_id = uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap();
20060 let plot = flatland_protocol::PropertyPlotView {
20061 plot_id,
20062 property_zone_id: "zone_a".into(),
20063 zone_label: Some("Starter Town East".into()),
20064 deed_instance_id: uuid::Uuid::nil(),
20065 x0: 0.0,
20066 y0: 0.0,
20067 x1: 4.0,
20068 y1: 4.0,
20069 upkeep_copper_per_day: 1,
20070 arrears_days: 0,
20071 is_mine: true,
20072 may_farm: true,
20073 purchase_basis_copper: 0,
20074 farm_public: false,
20075 public_tax_discount_bps: 0,
20076 farm_allow: vec![],
20077 owner_character_id: None,
20078 owner_label: Some("Madsin".into()),
20079 building_id: None,
20080 plot_code: "xyz1234a".into(),
20081 label: "Food Pad".into(),
20082 };
20083 assert_eq!(
20084 super::plot_stop_label(&[plot.clone()], plot_id),
20085 "Madsin — Starter Town East — Food Pad"
20086 );
20087 let missing = uuid::Uuid::parse_str("1a001590-0000-4000-8000-000000000002").unwrap();
20088 assert_eq!(super::plot_stop_label(&[plot], missing), "plot 1a001590");
20089 }
20090}